Coverage for transformer_lens/model_bridge/generalized_components/block.py: 90%

264 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2026-10-23 20:45 +0000

1"""Block bridge component. 

2 

3This module contains the bridge component for transformer blocks. 

4""" 

5from __future__ import annotations 

6 

7import inspect 

8import re 

9import weakref 

10from typing import Any, Callable, Dict, Optional, cast 

11 

12import torch 

13 

14from transformer_lens.hook_points import HookPoint 

15from transformer_lens.model_bridge.exceptions import StopAtLayerException 

16from transformer_lens.model_bridge.generalized_components.base import ( 

17 GeneralizedComponent, 

18) 

19 

20# Layer-type variant submodule names. Tuple for deterministic iteration order. 

21# Extend here when adding new hybrid variant types. 

22VARIANT_SUBMODULE_NAMES: tuple[str, ...] = ("attn", "linear_attn", "mamba", "mixer", "ssm") 

23_VARIANT_SUBMODULE_SET: frozenset[str] = frozenset(VARIANT_SUBMODULE_NAMES) 

24 

25# Infrastructure modules excluded from submodule introspection. 

26_BLOCK_INTERNAL_MODULES: frozenset[str] = frozenset({"hook_in", "hook_out", "_original_component"}) 

27 

28# Norm-module prefixes excluded from layer_types() labels. 

29_NORM_PREFIXES: tuple[str, ...] = ("ln", "layer_norm", "norm", "rms") 

30 

31 

32class BlockBridge(GeneralizedComponent): 

33 """Bridge component for transformer blocks. 

34 

35 This component provides standardized input/output hooks and monkey-patches 

36 HuggingFace blocks to insert hooks at positions matching HookedTransformer. 

37 """ 

38 

39 is_list_item: bool = True 

40 hook_out_is_single_residual_stream: bool = True 

41 # hook_mlp_in is a direct HookPoint on this class (not aliased) so it can 

42 # fire on the MLP-branch entry (pre-ln2, or the MLP input on post-norm 

43 # blocks); see __init__. The normalized mlp input stays at block.mlp.hook_in. 

44 hook_aliases = { 

45 "hook_resid_pre": "hook_in", 

46 "hook_resid_mid": "ln2.hook_in", 

47 "hook_resid_post": "hook_out", 

48 "hook_attn_in": "attn.hook_attn_in", 

49 "hook_attn_out": "attn.hook_out", 

50 "hook_q_input": "attn.hook_q_input", 

51 "hook_k_input": "attn.hook_k_input", 

52 "hook_v_input": "attn.hook_v_input", 

53 "hook_mlp_out": "mlp.hook_out", 

54 } 

55 

56 def __init__( 

57 self, 

58 name: str, 

59 config: Optional[Any] = None, 

60 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

61 hook_alias_overrides: Optional[Dict[str, str]] = None, 

62 mlp_reads_resid_directly: bool = False, 

63 ): 

64 """Initialize the block bridge. 

65 

66 Args: 

67 name: The name of the component in the model 

68 config: Optional configuration (unused for BlockBridge) 

69 submodules: Dictionary of submodules to register 

70 hook_alias_overrides: Optional dictionary to override default hook aliases. 

71 For example, {"hook_attn_out": "ln1_post.hook_out"} will make hook_attn_out 

72 point to ln1_post.hook_out instead of the default attn.hook_out. 

73 mlp_reads_resid_directly: True when the MLP consumes the mid-residual with 

74 no pre-MLP norm. Moves the hook_mlp_in capture from ln2 to the MLP 

75 itself. Implied for post-norm-only blocks (ln2_post without ln2). 

76 """ 

77 has_ln2 = submodules is not None and "ln2" in submodules 

78 # OLMo 2 layout: ln2_post without ln2 means the MLP reads the mid-residual directly. 

79 post_norm_only_mlp = submodules is not None and "ln2_post" in submodules and not has_ln2 

80 # ln1_post/ln2_post redirect attn_out/mlp_out to match HookedTransformer's 

81 # placement (hook fires after the post-norm, not before). 

82 auto_overrides = {} 

83 if submodules is not None: 

84 if "ln1_post" in submodules: 

85 auto_overrides["hook_attn_out"] = "ln1_post.hook_out" 

86 if "ln2_post" in submodules: 

87 auto_overrides["hook_mlp_out"] = "ln2_post.hook_out" 

88 if post_norm_only_mlp: 

89 auto_overrides["hook_resid_mid"] = "mlp.hook_in" 

90 merged_overrides = {**auto_overrides, **(hook_alias_overrides or {})} 

91 

92 # Guard against the bug where a sequential block (attn + mlp) with no MLP-side 

93 # norm silently points hook_resid_mid at the wrong tensor. Use 

94 # ParallelBlockBridge for parallel-residual architectures. 

95 # Skip the check on generic-container / attn-only uses (no mlp). 

96 has_attn_like = submodules is not None and any( 

97 k in submodules for k in _VARIANT_SUBMODULE_SET 

98 ) 

99 has_mlp = submodules is not None and "mlp" in submodules 

100 if ( 

101 has_attn_like 

102 and has_mlp 

103 and not has_ln2 

104 and not post_norm_only_mlp 

105 and type(self) is BlockBridge 

106 ): 

107 raise ValueError( 

108 f"BlockBridge at '{name}': neither 'ln2' nor 'ln2_post' declared. " 

109 f"Declare ln2 (pre-MLP norm) or ln2_post (post-MLP norm), or use " 

110 f"ParallelBlockBridge for a parallel-residual architecture." 

111 ) 

112 

113 super().__init__( 

114 name, 

115 config, 

116 submodules=submodules if submodules is not None else {}, 

117 hook_alias_overrides=merged_overrides if merged_overrides else None, 

118 ) 

119 

120 self._original_block_forward: Optional[Callable[..., Any]] = None 

121 self._capture_hooks_wired: bool = False 

122 self._capture_hook_handles: list[torch.utils.hooks.RemovableHandle] = [] 

123 # Fallback for _read_use_hook_mlp_in when block.config is None. 

124 self._use_hook_mlp_in: bool = False 

125 self.mlp_reads_resid_directly = mlp_reads_resid_directly or post_norm_only_mlp 

126 # Fires on the MLP-branch entry (pre-ln2, or the MLP input on post-norm 

127 # blocks) when use_hook_mlp_in is set. See #1317. 

128 self.hook_mlp_in = HookPoint() 

129 

130 def _wire_ln1_module(self) -> None: 

131 """Keep the raw ln1 execution reference outside the ownership tree.""" 

132 from transformer_lens.model_bridge.generalized_components.attention import ( 

133 AttentionBridge, 

134 ) 

135 

136 ln1 = self.submodules.get("ln1") if self.submodules else None 

137 attn = self.submodules.get("attn") if self.submodules else None 

138 if not isinstance(attn, AttentionBridge): 

139 return 

140 

141 ln1_module = None 

142 if ( 

143 ln1 is not None 

144 and getattr(attn, "supports_split_qkv_fork", False) 

145 and getattr(ln1, "original_component", None) is not None 

146 ): 

147 ln1_module = ln1.original_component 

148 

149 attn._modules.pop("_ln1_module", None) 

150 object.__setattr__(attn, "_ln1_module", ln1_module) 

151 

152 def _maybe_wire_capture_hooks(self) -> None: 

153 """Install the block's capture hooks (split-qkv fork, hook_mlp_in). 

154 

155 Registered on the bridge submodule, not ``original_component`` — the 

156 manual bridge forward never calls the raw module. Idempotent. 

157 """ 

158 self._wire_ln1_module() 

159 if self._capture_hooks_wired: 

160 return 

161 from transformer_lens.model_bridge.generalized_components.attention import ( 

162 AttentionBridge, 

163 ) 

164 

165 ln1 = self.submodules.get("ln1") if self.submodules else None 

166 attn = self.submodules.get("attn") if self.submodules else None 

167 if ( 

168 ln1 is not None 

169 and isinstance(attn, AttentionBridge) 

170 and getattr(attn, "supports_split_qkv_fork", False) 

171 and getattr(ln1, "original_component", None) is not None 

172 ): 

173 attn_ref = cast(AttentionBridge, weakref.proxy(attn)) 

174 

175 def _capture_pre_ln1(_module: torch.nn.Module, args: tuple) -> None: 

176 if args and isinstance(args[0], torch.Tensor): 176 ↛ exitline 176 didn't return from function '_capture_pre_ln1' because the condition on line 176 was always true

177 attn_ref._captured_pre_ln_residual = args[0] 

178 

179 handle = ln1.register_forward_pre_hook(_capture_pre_ln1) 

180 self._capture_hook_handles.append(handle) 

181 

182 # hook_mlp_in must capture the MLP-branch entry point: ln2's input on 

183 # pre-norm blocks, the MLP's own input on post-norm blocks (where ln2 

184 # follows the MLP and its input is the raw MLP output). 

185 if self.mlp_reads_resid_directly: 

186 capture_target = self.submodules.get("mlp") if self.submodules else None 

187 else: 

188 capture_target = self.submodules.get("ln2") if self.submodules else None 

189 if ( 

190 capture_target is not None 

191 and getattr(capture_target, "original_component", None) is not None 

192 ): 

193 hook_mlp_in = self.hook_mlp_in 

194 block_ref = weakref.proxy(self) 

195 

196 def _capture_mlp_in(_module: torch.nn.Module, args: tuple) -> Any: 

197 if not block_ref._read_use_hook_mlp_in(): 

198 return None 

199 if args and isinstance(args[0], torch.Tensor): 199 ↛ 202line 199 didn't jump to line 202 because the condition on line 199 was always true

200 hooked = hook_mlp_in(args[0]) 

201 return (hooked,) + args[1:] 

202 return None 

203 

204 handle = capture_target.register_forward_pre_hook(_capture_mlp_in) 

205 self._capture_hook_handles.append(handle) 

206 

207 self._capture_hooks_wired = True 

208 

209 def _teardown_capture_hooks(self) -> None: 

210 """Remove the capture hooks installed by _maybe_wire_capture_hooks (and subclass extensions).""" 

211 for handle in self._capture_hook_handles: 

212 handle.remove() 

213 self._capture_hook_handles.clear() 

214 self._capture_hooks_wired = False 

215 

216 def _read_use_hook_mlp_in(self) -> bool: 

217 """Prefer ``block.config.use_hook_mlp_in``; fall back to the block-local flag.""" 

218 cfg = self.config 

219 if cfg is not None and hasattr(cfg, "use_hook_mlp_in"): 

220 return bool(cfg.use_hook_mlp_in) 

221 return self._use_hook_mlp_in 

222 

223 def _clear_attention_capture(self) -> None: 

224 """Release the transient residual captured for attention input forks.""" 

225 from transformer_lens.model_bridge.generalized_components.attention import ( 

226 AttentionBridge, 

227 ) 

228 

229 attn = self.submodules.get("attn") if self.submodules else None 

230 if isinstance(attn, AttentionBridge): 

231 attn._captured_pre_ln_residual = None 

232 

233 def forward(self, *args: Any, **kwargs: Any) -> Any: 

234 """Forward pass through the block bridge. 

235 

236 Args: 

237 *args: Input arguments 

238 **kwargs: Input keyword arguments 

239 

240 Returns: 

241 The output from the original component 

242 

243 Raises: 

244 StopAtLayerException: If stop_at_layer is set and this block should stop execution 

245 """ 

246 if self.original_component is None: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 raise RuntimeError( 

248 f"Original component not set for {self.name}. Call set_original_component() first." 

249 ) 

250 

251 self._maybe_wire_capture_hooks() 

252 self._clear_attention_capture() 

253 args, kwargs = self._maybe_inject_start_residual(args, kwargs) 

254 self._check_stop_at_layer(*args, **kwargs) 

255 args, kwargs = self._hook_input_hidden_states(args, kwargs) 

256 

257 # Filter kwargs to only include parameters accepted by the original component 

258 # This prevents errors when passing encoder-specific params to decoder-only models 

259 filtered_kwargs = self._filter_kwargs_for_forward(kwargs, len(args)) 

260 

261 try: 

262 output = self.original_component(*args, **filtered_kwargs) 

263 finally: 

264 self._clear_attention_capture() 

265 force_tuple_for_bare_tensor = self._is_standalone_hidden_state_call(args, filtered_kwargs) 

266 return self._apply_output_hook( 

267 output, force_tuple_for_bare_tensor=force_tuple_for_bare_tensor 

268 ) 

269 

270 def _apply_output_hook( 

271 self, 

272 output: Any, 

273 wrap_single_element: bool = True, 

274 force_tuple_for_bare_tensor: bool = False, 

275 ) -> Any: 

276 """Hook the primary tensor in the output and return the result. 

277 

278 Args: 

279 output: Raw output from the original component (tensor or tuple). 

280 wrap_single_element: If True, single-element tuples stay as tuples after 

281 hooking (default, required by most HF models). If False, single-element 

282 tuples are unwrapped to a bare tensor (Bloom convention). 

283 force_tuple_for_bare_tensor: If True, bare tensor outputs are wrapped into 

284 a one-element tuple after hooking. This keeps standalone BlockBridge 

285 calls compatible with HF block APIs that expose tuple-like block outputs, 

286 while preserving tensor outputs during newer HF parent-model execution. 

287 """ 

288 if isinstance(output, tuple) and len(output) > 0: 

289 first = output[0] 

290 if isinstance(first, torch.Tensor): 290 ↛ 295line 290 didn't jump to line 295 because the condition on line 290 was always true

291 first = self.hook_out(first) 

292 if len(output) == 1: 

293 return (first,) if wrap_single_element else first 

294 output = (first,) + output[1:] 

295 return output 

296 if isinstance(output, torch.Tensor): 

297 output = self.hook_out(output) 

298 if force_tuple_for_bare_tensor and wrap_single_element: 

299 return (output,) 

300 return output 

301 return output 

302 

303 @staticmethod 

304 def _is_standalone_hidden_state_call(args: tuple, kwargs: dict) -> bool: 

305 """Return True for direct block(hidden_states) style calls. 

306 

307 Transformers versions differ on whether parent model loops expect block 

308 outputs as tuples or tensors. We preserve the original tensor return during 

309 full-model execution, but expose tuple-like output for standalone component 

310 calls so `output[0]` does not accidentally drop the batch dimension. 

311 """ 

312 if len(args) == 1 and isinstance(args[0], torch.Tensor) and not kwargs: 

313 return True 

314 return ( 

315 len(args) == 0 

316 and set(kwargs.keys()) == {"hidden_states"} 

317 and isinstance(kwargs["hidden_states"], torch.Tensor) 

318 ) 

319 

320 def _extract_layer_idx(self) -> Optional[int]: 

321 """Parse this block's layer index from its name (TL/GPT-2/LLaMA patterns).""" 

322 if self.name is None: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 return None 

324 match = re.search(r"(?:^|\.)(?:blocks|h|layers)\.(\d+)", self.name) 

325 return int(match.group(1)) if match else None 

326 

327 def _check_stop_at_layer(self, *args: Any, **kwargs: Any) -> None: 

328 """Check if execution should stop before this block. Raises StopAtLayerException. 

329 

330 The _stop_at_layer_idx attribute is set by the bridge's forward method. 

331 Supports TL/GPT-2/LLaMA naming patterns for layer index extraction. 

332 """ 

333 if getattr(self, "_stop_at_layer_idx", None) is None: 

334 return 

335 layer_idx = self._extract_layer_idx() 

336 if layer_idx is not None and layer_idx == self._stop_at_layer_idx: 

337 if len(args) > 0 and isinstance(args[0], torch.Tensor): 337 ↛ 339line 337 didn't jump to line 339 because the condition on line 337 was always true

338 input_tensor = args[0] 

339 elif "hidden_states" in kwargs and isinstance(kwargs["hidden_states"], torch.Tensor): 

340 input_tensor = kwargs["hidden_states"] 

341 else: 

342 raise ValueError(f"Cannot find input tensor to stop at layer {layer_idx}") 

343 input_tensor = self.hook_in(input_tensor) 

344 raise StopAtLayerException(input_tensor) 

345 

346 def _maybe_inject_start_residual(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]: 

347 """If this is the start_at_layer block, swap in the caller's residual. 

348 

349 Mirror of ``_check_stop_at_layer``: the bridge's forward stashes the 

350 residual-stream input on the block via ``_start_residual`` and sets 

351 ``_start_at_layer_idx``. This block replaces its incoming hidden states 

352 with that residual; ``_hook_input_hidden_states`` then fires ``hook_in`` 

353 on it, so ``hook_resid_pre`` reflects the injected value. 

354 """ 

355 if getattr(self, "_start_at_layer_idx", None) is None: 

356 return args, kwargs 

357 if self._extract_layer_idx() != self._start_at_layer_idx: 

358 return args, kwargs 

359 residual = getattr(self, "_start_residual", None) 

360 if residual is None: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 return args, kwargs 

362 if len(args) > 0 and isinstance(args[0], torch.Tensor): 362 ↛ 364line 362 didn't jump to line 364 because the condition on line 362 was always true

363 args = (residual,) + args[1:] 

364 elif "hidden_states" in kwargs: 

365 kwargs = {**kwargs, "hidden_states": residual} 

366 return args, kwargs 

367 

368 def _hook_input_hidden_states(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]: 

369 """Apply hook_in to the hidden_states input, whether in args or kwargs.""" 

370 if len(args) > 0 and isinstance(args[0], torch.Tensor): 370 ↛ 373line 370 didn't jump to line 373 because the condition on line 370 was always true

371 hooked_input = self.hook_in(args[0]) 

372 args = (hooked_input,) + args[1:] 

373 elif "hidden_states" in kwargs and isinstance(kwargs["hidden_states"], torch.Tensor): 

374 kwargs["hidden_states"] = self.hook_in(kwargs["hidden_states"]) 

375 return args, kwargs 

376 

377 def _filter_kwargs_for_forward( 

378 self, kwargs: Dict[str, Any], num_positional_args: int = 0 

379 ) -> Dict[str, Any]: 

380 """Filter kwargs to only include parameters accepted by original_component.forward(). 

381 

382 This prevents TypeErrors when the bridge passes parameters (like encoder_attention_mask) 

383 that aren't accepted by decoder-only models. It also removes any kwargs that would 

384 conflict with positional arguments already being passed. 

385 

386 Args: 

387 kwargs: The full set of keyword arguments 

388 num_positional_args: Number of positional arguments being passed (to avoid conflicts) 

389 

390 Returns: 

391 Filtered kwargs containing only accepted parameters 

392 """ 

393 if self.original_component is None: 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true

394 return kwargs 

395 

396 try: 

397 # Get the signature of the original component's forward method 

398 sig = inspect.signature(self.original_component.forward) 

399 param_list = list(sig.parameters.keys()) 

400 valid_params = set(param_list) 

401 

402 # Check if the signature accepts **kwargs (VAR_KEYWORD) 

403 accepts_var_keyword = any( 

404 p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() 

405 ) 

406 

407 # If it accepts **kwargs, pass everything through 

408 if accepts_var_keyword: 

409 return kwargs 

410 

411 # Skip params already provided positionally 

412 positional_param_names = set(param_list[:num_positional_args]) 

413 

414 # Filter kwargs: include only if in signature AND not already provided positionally 

415 filtered = { 

416 k: v 

417 for k, v in kwargs.items() 

418 if k in valid_params and k not in positional_param_names 

419 } 

420 return filtered 

421 

422 except (ValueError, TypeError): 

423 # If we can't inspect the signature, pass through all kwargs 

424 # (better to potentially fail than to silently drop important params) 

425 return kwargs 

426 

427 

428class MLABlockBridge(BlockBridge): 

429 """Block wrapping Multi-Head Latent Attention (DeepSeek V2/V3/R1). 

430 

431 MLA has no standalone q/k/v projections — Q flows through compressed 

432 q_a_proj→q_a_layernorm→q_b_proj, and K/V share a joint kv_a_proj_with_mqa 

433 entry point. There is no single HookPoint that represents "input that 

434 becomes Q/K/V", so the block-level ``hook_q_input``/``hook_k_input``/ 

435 ``hook_v_input``/``hook_attn_in`` aliases do not apply. Type-level 

436 distinction means a reader of the adapter sees ``MLABlockBridge`` and 

437 knows those hooks are absent. 

438 """ 

439 

440 def __init__( 

441 self, 

442 name: str, 

443 config: Optional[Any] = None, 

444 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

445 hook_alias_overrides: Optional[Dict[str, str]] = None, 

446 ): 

447 super().__init__( 

448 name, 

449 config=config, 

450 submodules=submodules, 

451 hook_alias_overrides=hook_alias_overrides, 

452 ) 

453 if self.hook_aliases is BlockBridge.hook_aliases: 453 ↛ 455line 453 didn't jump to line 455 because the condition on line 453 was always true

454 self.hook_aliases = dict(self.hook_aliases) 

455 for alias in ("hook_q_input", "hook_k_input", "hook_v_input", "hook_attn_in"): 

456 self.hook_aliases.pop(alias, None) 

457 

458 

459class ParallelBlockBridge(BlockBridge): 

460 """Block where attn and MLP both read the pre-attention residual. 

461 

462 For GPT-J, NeoX, Pythia, Phi, Cohere, CodeGen, and some Falcon variants, 

463 output = resid_pre + attn_out + mlp_out — no distinct post-attention 

464 residual exists. Matches legacy HookedTransformer which omits hook_resid_mid 

465 when ``cfg.parallel_attn_mlp=True``. Type-level distinction means a reader 

466 of the adapter sees ``ParallelBlockBridge`` and knows the hook is absent. 

467 """ 

468 

469 def __init__( 

470 self, 

471 name: str, 

472 config: Optional[Any] = None, 

473 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

474 hook_alias_overrides: Optional[Dict[str, str]] = None, 

475 ): 

476 super().__init__( 

477 name, 

478 config=config, 

479 submodules=submodules, 

480 hook_alias_overrides=hook_alias_overrides, 

481 ) 

482 # Ensure instance-level copy before mutating; base may have left the 

483 # class-level dict shared when no overrides were passed. 

484 if self.hook_aliases is BlockBridge.hook_aliases: 484 ↛ 486line 484 didn't jump to line 486 because the condition on line 484 was always true

485 self.hook_aliases = dict(self.hook_aliases) 

486 self.hook_aliases.pop("hook_resid_mid", None) 

487 

488 

489class ScaledResidualBlockBridge(BlockBridge): 

490 """Block whose sublayer outputs are scaled before the residual add. 

491 

492 Granite-family HF blocks compute ``residual + sublayer_out * residual_multiplier`` 

493 inline, so no submodule output equals the tensor added to the residual stream 

494 and the legacy aliases cannot be fixed by re-pointing. ``hook_attn_out`` / 

495 ``hook_mlp_out`` become real HookPoints on the block firing on the scaled 

496 contribution: 

497 

498 - no hooks attached: the forward is untouched (bit-exact); 

499 - read-only hooks: they observe ``module_out * scale``, forward stays bit-exact; 

500 - a hook that changes the tensor (returned new or mutated in place): the module 

501 output is rewritten to ``hooked / scale`` so HF's multiply reconstructs the 

502 written value as the contribution (~1-ulp rounding; exact for zero-ablation); 

503 - any backward hooks: the rewrite always happens so the autograd graph routes 

504 through the HookPoint. 

505 """ 

506 

507 def __init__( 

508 self, 

509 name: str, 

510 config: Optional[Any] = None, 

511 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

512 hook_alias_overrides: Optional[Dict[str, str]] = None, 

513 mlp_reads_resid_directly: bool = False, 

514 residual_contribution_scale: float = 1.0, 

515 scaled_attn_submodule: str = "attn", 

516 scaled_mlp_submodule: Optional[str] = "mlp", 

517 ): 

518 """scaled_mlp_submodule=None when no single submodule feeds the MLP-side 

519 add (GraniteMoeHybrid sums moe + shared_mlp inline) — hook_mlp_out then 

520 stays absent rather than firing with a partial tensor.""" 

521 super().__init__( 

522 name, 

523 config=config, 

524 submodules=submodules, 

525 hook_alias_overrides=hook_alias_overrides, 

526 mlp_reads_resid_directly=mlp_reads_resid_directly, 

527 ) 

528 scale = float(residual_contribution_scale) 

529 if scale == 0.0: 

530 raise ValueError( 

531 f"ScaledResidualBlockBridge at '{name}': residual_contribution_scale " 

532 f"must be nonzero (the write path divides by it)." 

533 ) 

534 self.residual_contribution_scale = scale 

535 self.scaled_attn_submodule = scaled_attn_submodule 

536 self.scaled_mlp_submodule = scaled_mlp_submodule 

537 attn = self.submodules.get(scaled_attn_submodule) if self.submodules else None 

538 if attn is not None: 538 ↛ 540line 538 didn't jump to line 540 because the condition on line 538 was always true

539 attn._result_contribution_scale = scale 

540 if self.hook_aliases is BlockBridge.hook_aliases: 540 ↛ 542line 540 didn't jump to line 542 because the condition on line 540 was always true

541 self.hook_aliases = dict(self.hook_aliases) 

542 for alias in ("hook_attn_out", "hook_mlp_out"): 

543 self.hook_aliases.pop(alias, None) 

544 self.hook_attn_out = HookPoint() 

545 if scaled_mlp_submodule is not None: 

546 self.hook_mlp_out = HookPoint() 

547 

548 def set_original_component(self, original_component: torch.nn.Module) -> None: 

549 """Prune contribution HookPoints the bound layer cannot fire. 

550 

551 Heterogeneous blocks (GraniteMoeHybrid mamba layers) lack the attn 

552 submodule; a HookPoint that exists but never fires is a silent-no-op 

553 intervention trap, so the name must be absent instead. 

554 """ 

555 super().set_original_component(original_component) 

556 targets = [(self.scaled_attn_submodule, "hook_attn_out")] 

557 if self.scaled_mlp_submodule is not None: 

558 targets.append((self.scaled_mlp_submodule, "hook_mlp_out")) 

559 for sub_name, hook_name in targets: 

560 sub = self.submodules.get(sub_name) if self.submodules else None 

561 remote = getattr(sub, "name", None) 

562 first = remote.split(".", 1)[0] if isinstance(remote, str) else None 

563 missing = sub is None or ( 

564 first is not None and getattr(original_component, first, None) is None 

565 ) 

566 if missing: 

567 if hasattr(self, hook_name): 567 ↛ 571line 567 didn't jump to line 571 because the condition on line 567 was always true

568 delattr(self, hook_name) 

569 # __setattr__ auto-registered the HookPoint; get_hooks() serves 

570 # from this registry, so the module deletion alone is not enough. 

571 self._hook_registry.pop(hook_name, None) 

572 

573 def _maybe_wire_capture_hooks(self) -> None: 

574 """Extend the base wiring with the scaled-contribution forward hooks. 

575 

576 Shares the base flag and handle list so _teardown_capture_hooks also 

577 removes these hooks and re-wiring stays idempotent. 

578 """ 

579 if self._capture_hooks_wired: 

580 return 

581 super()._maybe_wire_capture_hooks() 

582 targets = [(self.scaled_attn_submodule, "hook_attn_out")] 

583 if self.scaled_mlp_submodule is not None: 

584 targets.append((self.scaled_mlp_submodule, "hook_mlp_out")) 

585 for sub_name, hook_name in targets: 

586 hook_point = getattr(self, hook_name, None) 

587 sub = self.submodules.get(sub_name) if self.submodules else None 

588 if ( 

589 hook_point is None 

590 or sub is None 

591 or getattr(sub, "original_component", None) is None 

592 ): 

593 continue 

594 handle = sub.register_forward_hook(self._make_scaled_contribution_hook(hook_point)) 

595 self._capture_hook_handles.append(handle) 

596 

597 def _make_scaled_contribution_hook( 

598 self, hook_point: HookPoint 

599 ) -> Callable[[torch.nn.Module, tuple, Any], Any]: 

600 """Build a forward hook exposing ``output * scale`` through hook_point.""" 

601 scale = self.residual_contribution_scale 

602 

603 def _hook(_module: torch.nn.Module, _args: tuple, output: Any) -> Any: 

604 if not hook_point.has_hooks(dir="both"): 

605 return None 

606 is_tuple = isinstance(output, tuple) 

607 out = output[0] if is_tuple else output 

608 if not isinstance(out, torch.Tensor): 608 ↛ 609line 608 didn't jump to line 609 because the condition on line 608 was never true

609 return None 

610 new = self._hook_scaled_output(out, hook_point, scale) 

611 if new is out: 

612 return None 

613 return ((new,) + output[1:]) if is_tuple else new 

614 

615 return _hook 

616 

617 

618class DelegatedAttentionBlockBridge(BlockBridge): 

619 """Block whose attention is delegated wholesale to HF (no split-qkv fork). 

620 

621 For architectures with heterogeneous per-layer attention structure — e.g. 

622 Gemma 4, where KV-shared layers have no ``k_proj``/``v_proj`` at all and 

623 K==V layers have no ``v_proj`` — there is no uniform HookPoint that 

624 represents "input that becomes Q/K/V", so the block-level ``hook_q_input``/ 

625 ``hook_k_input``/``hook_v_input``/``hook_attn_in`` aliases do not apply. 

626 Type-level distinction means a reader of the adapter sees 

627 ``DelegatedAttentionBlockBridge`` and knows those hooks are absent. 

628 """ 

629 

630 # Tell the component benchmark this block's attention is delegated wholesale 

631 # to HF and cannot be tested in isolation (requires model-specific kwargs). 

632 maintain_native_attention: bool = True 

633 

634 def __init__( 

635 self, 

636 name: str, 

637 config: Optional[Any] = None, 

638 submodules: Optional[Dict[str, GeneralizedComponent]] = None, 

639 hook_alias_overrides: Optional[Dict[str, str]] = None, 

640 ): 

641 super().__init__( 

642 name, 

643 config=config, 

644 submodules=submodules, 

645 hook_alias_overrides=hook_alias_overrides, 

646 ) 

647 if self.hook_aliases is BlockBridge.hook_aliases: 

648 self.hook_aliases = dict(self.hook_aliases) 

649 for alias in ("hook_q_input", "hook_k_input", "hook_v_input", "hook_attn_in"): 

650 self.hook_aliases.pop(alias, None)