Coverage for transformer_lens/tools/analysis/attribution_patching.py: 95%

585 statements  

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

1"""Attribution patching — linearized activation patching on ``TransformerBridge``. 

2 

3Attribution patching estimates the causal effect of every model component on a 

4task metric with a *gradient-based linearization* of activation patching: rather 

5than one forward pass per intervention, it reads a single gradient cache. For a 

6clean/corrupt prompt pair it runs a clean forward (for ``a_clean``) and a corrupt 

7forward whose backward hooks capture ``g = d(metric)/d(a)`` (for ``a_corrupt`` and 

8its gradient), and scores each node with the 

9first-order Taylor estimate ``effect(node) = (a_clean - a_corrupt) . g``. Scores 

10over a batch of clean/corrupt pairs are averaged before ranking. 

11 

12Only the ``TransformerBridge`` API is targeted. 

13 

14Sign/direction convention (denoising form): the gradient is taken on the 

15*corrupt* run and the estimate points *toward* the clean activation, so a 

16positive score means patching that node from corrupt toward clean moves the 

17metric in the positive direction. An oracle-parity test maps this convention 

18onto a pinned reference rather than assuming the two agree. 

19 

20Memory note: gradients are retained only for hook points passing ``names_filter``. 

21Retaining gradients at every hook point roughly doubles cache memory, so callers 

22should filter to the hook families their analysis actually reads. 

23 

24Scope: this build ships node and edge granularity with plain attribution 

25(``ig_steps=1``), plus ablate-outside faithfulness. The integrated-gradient path 

26(EAP-IG, ``ig_steps>1``) is not implemented yet; its API is declared here, and 

27``ig_steps>1`` raises :class:`NotImplementedError`, so downstream code can pin 

28against a stable surface now. 

29 

30Faithfulness intervenes rather than estimating: it ablates every edge outside a 

31candidate circuit and reports how much of the clean-to-corrupt metric gap the 

32circuit recovers. Because the residual stream is a running sum and the reader 

33hooks fire before the layer norm, each reader's input is rebuilt exactly. 

34""" 

35 

36from __future__ import annotations 

37 

38from contextlib import contextmanager 

39from dataclasses import dataclass, field 

40from typing import ( 

41 Any, 

42 Callable, 

43 Collection, 

44 Iterator, 

45 Literal, 

46 Optional, 

47 Sequence, 

48 Union, 

49 cast, 

50 get_args, 

51) 

52 

53import torch 

54 

55from transformer_lens.model_bridge.generalized_components.block import ( 

56 VARIANT_SUBMODULE_NAMES, 

57) 

58from transformer_lens.tools.analysis._model_state import require_eval_mode 

59 

60MetricFn = Callable[[torch.Tensor], torch.Tensor] 

61NamesFilter = Union[str, Sequence[str], Callable[[str], bool], None] 

62 

63NodeKind = Literal[ 

64 "embed", "attn_head_out", "mlp_out", "q_input", "k_input", "v_input", "mlp_in", "logits" 

65] 

66Granularity = Literal["node", "edge"] 

67AblationMode = Literal["corrupt", "mean"] 

68EdgeClass = Literal["into_qk", "into_v", "into_mlp", "into_logits"] 

69 

70 

71@dataclass 

72class GradientCache: 

73 """Activations and their metric-gradients from one forward + backward pass. 

74 

75 Attributes: 

76 activations: Detached activation tensor per cached hook name. 

77 gradients: ``d(metric)/d(activation)`` per cached hook name. 

78 metric: The scalar metric value at this run (detached). 

79 """ 

80 

81 activations: dict[str, torch.Tensor] 

82 gradients: dict[str, Optional[torch.Tensor]] 

83 metric: torch.Tensor 

84 

85 

86@dataclass(frozen=True) 

87class Node: 

88 """A node in the residual-stream computational graph. 

89 

90 Nodes are the typed, hashable keys the attribution sweep scores. Each node 

91 is identified by ``(kind, layer, position, head)``; ``kind`` selects the node 

92 family and constrains which of ``layer``/``head`` apply. Three kinds are 

93 *writers* -- they contribute a value into the residual stream: 

94 

95 - ``"embed"``: the token embedding write. ``layer`` and ``head`` are ``None``. 

96 - ``"attn_head_out"``: one attention head's output. ``layer`` and ``head`` set. 

97 - ``"mlp_out"``: one layer's MLP output. ``layer`` set, ``head`` is ``None``. 

98 

99 Five kinds are *readers* -- they consume the residual stream as an edge's 

100 destination (see :func:`enumerate_edges`): 

101 

102 - ``"q_input"`` / ``"k_input"`` / ``"v_input"``: one attention head's split 

103 Q/K/V input. ``layer`` and ``head`` set. 

104 - ``"mlp_in"``: one layer's MLP entry. ``layer`` set, ``head`` is ``None``. 

105 - ``"logits"``: the terminal readout of the final residual, read at 

106 ``blocks.{n_layers-1}.hook_resid_post``. ``layer`` is that final layer and 

107 ``head`` is ``None``. Every writer feeds this reader, so a writer's 

108 aggregate over its outgoing edges equals its direct node score. 

109 

110 ``position`` is the sequence index the node is read at. The invariants above 

111 are enforced in ``__post_init__`` so a malformed key raises rather than 

112 silently producing a wrong graph. 

113 """ 

114 

115 kind: NodeKind 

116 position: int 

117 layer: Optional[int] = None 

118 head: Optional[int] = None 

119 

120 def __post_init__(self) -> None: 

121 if self.kind == "embed": 

122 if self.layer is not None or self.head is not None: 

123 raise ValueError("embed nodes take neither layer nor head") 

124 elif self.kind == "attn_head_out": 

125 if self.layer is None or self.head is None: 

126 raise ValueError("attn_head_out nodes need both layer and head") 

127 elif self.kind == "mlp_out": 

128 if self.layer is None: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 raise ValueError("mlp_out nodes need a layer") 

130 if self.head is not None: 

131 raise ValueError("mlp_out nodes take no head") 

132 elif self.kind in ("q_input", "k_input", "v_input"): 

133 if self.layer is None or self.head is None: 

134 raise ValueError(f"{self.kind} nodes need both layer and head") 

135 elif self.kind == "mlp_in": 

136 if self.layer is None: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true

137 raise ValueError("mlp_in nodes need a layer") 

138 if self.head is not None: 

139 raise ValueError("mlp_in nodes take no head") 

140 elif self.kind == "logits": 140 ↛ 146line 140 didn't jump to line 146 because the condition on line 140 was always true

141 if self.layer is None: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 raise ValueError("logits nodes need a layer") 

143 if self.head is not None: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 raise ValueError("logits nodes take no head") 

145 else: 

146 raise ValueError(f"unknown node kind {self.kind!r}") 

147 

148 @property 

149 def hook_name(self) -> str: 

150 """The cache hook point this node reads from. 

151 

152 Uses the standard ``TransformerBridge`` alias names (``hook_embed``, 

153 ``blocks.{l}.attn.hook_z``, ``blocks.{l}.hook_mlp_out``, 

154 ``blocks.{l}.attn.hook_q_input``/``hook_k_input``/``hook_v_input``, 

155 ``blocks.{l}.hook_mlp_in``, ``blocks.{l}.hook_resid_post``); the per-head 

156 nodes (``attn_head_out``, ``q_input``, ``k_input``, ``v_input``) slice 

157 head ``self.head`` out of the shared per-head tensor. 

158 """ 

159 if self.kind == "embed": 

160 return "hook_embed" 

161 if self.kind == "attn_head_out": 

162 return f"blocks.{self.layer}.attn.hook_z" 

163 if self.kind == "mlp_out": 

164 return f"blocks.{self.layer}.hook_mlp_out" 

165 if self.kind in ("q_input", "k_input", "v_input"): 

166 return f"blocks.{self.layer}.attn.hook_{self.kind}" 

167 if self.kind == "logits": 

168 return f"blocks.{self.layer}.hook_resid_post" 

169 return f"blocks.{self.layer}.hook_mlp_in" 

170 

171 

172@dataclass(frozen=True) 

173class EdgeAttributionConfig: 

174 """Configuration for an attribution-patching sweep. 

175 

176 The two axes are deliberately orthogonal: 

177 

178 - ``granularity`` selects what is scored: ``"node"`` scores each residual-stream 

179 write, ``"edge"`` scores each ``(source, destination)`` write->read pair. 

180 - ``ig_steps`` selects gradient fidelity. ``ig_steps=1`` is plain attribution 

181 patching / EAP: a single first-order Taylor gradient taken at the corrupt 

182 point. ``ig_steps>1`` is EAP-IG: the integrated gradient averaged over that 

183 many points along the corrupt->clean path, which corrects the gradient 

184 saturation that makes plain attribution unfaithful. 

185 

186 There is intentionally **no** ``method`` field. An earlier design had both a 

187 ``method`` enum (``"attribution"``/``"EAP"``/``"EAP-IG"``) and ``ig_steps``, 

188 which overlap: the method is fully determined by ``granularity`` and whether 

189 ``ig_steps`` exceeds 1. Collapsing them removes the invalid states (e.g. 

190 ``method="attribution", ig_steps=5``). 

191 

192 This build implements node and edge granularity with plain attribution. 

193 ``ig_steps>1`` is accepted by the type but raises :class:`NotImplementedError` 

194 at construction, so downstream code can import and reference this API now 

195 while the integrated-gradient path is not implemented. Once EAP-IG lands, 

196 the default flips to ``ig_steps=5`` (EAP-IG is the faithful default); until 

197 then the default is the only executable value, ``ig_steps=1``. 

198 

199 Attributes: 

200 granularity: ``"node"`` or ``"edge"``. Defaults to ``"node"``. 

201 ig_steps: Integrated-gradient path steps (``>=1``). Defaults to ``1``. 

202 """ 

203 

204 granularity: Granularity = "node" 

205 ig_steps: int = 1 

206 

207 def __post_init__(self) -> None: 

208 if self.granularity not in get_args(Granularity): 

209 raise ValueError( 

210 f"granularity must be one of {get_args(Granularity)}, got {self.granularity!r}" 

211 ) 

212 if self.ig_steps < 1: 

213 raise ValueError(f"ig_steps must be >= 1, got {self.ig_steps}") 

214 if self.ig_steps > 1: 

215 raise NotImplementedError( 

216 "ig_steps>1 (EAP-IG integrated gradients) is not implemented yet; this " 

217 "build supports ig_steps=1 (plain attribution) only." 

218 ) 

219 

220 

221@dataclass 

222class AttributionResult: 

223 """Scored output of an attribution-patching sweep. 

224 

225 Attributes: 

226 node_scores: Signed first-order effect estimate per node, 

227 ``(a_clean - a_corrupt) . d(metric)/d(a)``. A positive score means 

228 patching that node from corrupt toward clean moves the metric in the 

229 positive direction (the denoising convention pinned in the module 

230 docstring). For an edge-granularity sweep this is instead each 

231 writer's aggregate over its own outgoing edge scores. Because the 

232 graph has a terminal logits reader that every writer feeds, that 

233 aggregate equals the writer's direct node score (see 

234 :func:`attribution_patch`). 

235 edge_scores: Per-edge effect estimate keyed by ``(source, destination)``. 

236 Populated for an edge-granularity sweep; empty for a node sweep. 

237 """ 

238 

239 node_scores: dict[Node, float] 

240 edge_scores: dict[tuple[Node, Node], float] = field(default_factory=dict) 

241 

242 def top_nodes(self, k: int = 10) -> list[tuple[Node, float]]: 

243 """The ``k`` nodes with the largest effect magnitude, strongest first. 

244 

245 Ranking is by absolute score: a node with a large negative effect is as 

246 causally important as one with a large positive effect, so magnitude — not 

247 signed value — orders the circuit. Ties keep enumeration order (stable 

248 sort). Requesting more than the available nodes returns all of them. 

249 """ 

250 ranked = sorted(self.node_scores.items(), key=lambda item: abs(item[1]), reverse=True) 

251 return ranked[:k] 

252 

253 def top_edges(self, k: int = 10) -> list[tuple[Node, Node, float]]: 

254 """The ``k`` edges with the largest effect magnitude, strongest first. 

255 

256 Ranking is by absolute score, matching ``top_nodes``: a large negative 

257 edge effect is as causally important as a large positive one. Ties keep 

258 enumeration order (stable sort). Requesting more than the available 

259 edges returns all of them. 

260 """ 

261 ranked = sorted(self.edge_scores.items(), key=lambda item: abs(item[1]), reverse=True) 

262 return [(writer, reader, score) for (writer, reader), score in ranked[:k]] 

263 

264 

265@dataclass(frozen=True) 

266class FaithfulnessConfig: 

267 """Configuration for an ablate-outside faithfulness measurement. 

268 

269 Attributes: 

270 ablation: How an out-of-circuit edge's writer is replaced. 

271 ``"corrupt"`` (default) substitutes the corrupt run's own contribution; 

272 ``"mean"`` substitutes that writer's mean over the held-out 

273 ``mean_tokens`` batch passed to :func:`faithfulness`. 

274 """ 

275 

276 ablation: AblationMode = "corrupt" 

277 

278 def __post_init__(self) -> None: 

279 if self.ablation not in get_args(AblationMode): 

280 raise ValueError( 

281 f"ablation must be one of {get_args(AblationMode)}, got {self.ablation!r}" 

282 ) 

283 

284 

285@dataclass 

286class FaithfulnessResult: 

287 """How much of the clean-to-corrupt metric gap a candidate circuit recovers. 

288 

289 Attributes: 

290 recovered: ``(m_ablated - m_corrupt) / (m_clean - m_corrupt)``, a fraction 

291 (``1.0`` = clean metric, ``0.0`` = corrupt metric); values outside 

292 ``[0, 1]`` are possible, since a circuit can overshoot the clean run. 

293 full_metric: The clean run's metric, the top of the gap. 

294 corrupt_metric: The corrupt run's metric, the bottom of the gap. 

295 circuit_size: Number of edges kept. 

296 total_edges: Number of edges in the graph. 

297 edge_class_recovered: Recovery after also removing this class's retained 

298 edges, with the same replacements and gap; a class absent from the 

299 circuit has the aggregate recovery. Conditional dependence, not 

300 additive and not bounded to ``[0, 1]``. 

301 edge_class_counts: Full-graph edge counts per class, not retained counts. 

302 """ 

303 

304 recovered: float 

305 full_metric: float 

306 corrupt_metric: float 

307 circuit_size: int 

308 total_edges: int 

309 edge_class_recovered: dict[EdgeClass, float] = field(default_factory=dict) 

310 edge_class_counts: dict[EdgeClass, int] = field(default_factory=dict) 

311 

312 

313def _required_hook_names(n_layers: int, granularity: Granularity = "node") -> list[str]: 

314 """Hook points a sweep at ``granularity`` reads. 

315 

316 Node granularity needs the embed write plus each layer's attn-z and 

317 mlp-out. Edge granularity additionally needs the per-head hook points on 

318 both sides of an edge into or out of an attention head: ``attn.hook_result`` 

319 (writer -- a head's own contribution before the sum into the residual 

320 stream) and the split ``attn.hook_q_input``/``hook_k_input``/``hook_v_input`` 

321 (reader -- the residual each head's Q/K/V projection reads separately). 

322 """ 

323 names = ["hook_embed"] 

324 for layer in range(n_layers): 

325 names.append(f"blocks.{layer}.attn.hook_z") 

326 names.append(f"blocks.{layer}.hook_mlp_out") 

327 if granularity == "edge": 

328 names.append(f"blocks.{layer}.attn.hook_result") 

329 names.append(f"blocks.{layer}.attn.hook_q_input") 

330 names.append(f"blocks.{layer}.attn.hook_k_input") 

331 names.append(f"blocks.{layer}.attn.hook_v_input") 

332 return names 

333 

334 

335def _ensure_edge_hook_flags(model: Any) -> None: 

336 """Turn on the Bridge flags edge granularity's hook points require. 

337 

338 ``attn.hook_result``, the split ``attn.hook_q_input``/``hook_k_input``/ 

339 ``hook_v_input``, and ``hook_mlp_in`` all exist on the Bridge 

340 unconditionally but only fire when their owning flag 

341 (``cfg.use_attn_result`` / ``cfg.use_split_qkv_input`` / 

342 ``cfg.use_hook_mlp_in``) is on, so an edge sweep must enable all three 

343 before caching or the writer- and reader-side hook points it needs never 

344 populate. 

345 

346 Memory caveat: enabling ``use_attn_result``/``use_split_qkv_input`` makes 

347 every cached per-head tensor ``[batch, seq, n_heads, d_model]`` instead of 

348 the summed ``[batch, seq, d_model]`` residual. That is fine on a model the 

349 size of gpt2-small; it does not scale to models with many heads or layers. 

350 

351 Args: 

352 model: A ``TransformerBridge`` (or compatible) exposing ``cfg`` and 

353 ``set_use_attn_result``/``set_use_split_qkv_input``/ 

354 ``set_use_hook_mlp_in``. 

355 """ 

356 if not model.cfg.use_attn_result: 356 ↛ 358line 356 didn't jump to line 358 because the condition on line 356 was always true

357 model.set_use_attn_result(True) 

358 if not model.cfg.use_split_qkv_input: 358 ↛ 360line 358 didn't jump to line 360 because the condition on line 358 was always true

359 model.set_use_split_qkv_input(True) 

360 if not model.cfg.use_hook_mlp_in: 360 ↛ exitline 360 didn't return from function '_ensure_edge_hook_flags' because the condition on line 360 was always true

361 model.set_use_hook_mlp_in(True) 

362 

363 

364@contextmanager 

365def _edge_hook_flags(model: Any) -> Iterator[None]: 

366 """Enable the Bridge flags an edge sweep needs, then restore the caller's state. 

367 

368 ``attn.hook_result``, the split ``attn.hook_q_input``/``hook_k_input``/ 

369 ``hook_v_input``, and ``hook_mlp_in`` all exist on the Bridge unconditionally 

370 but only fire when their owning flag (``cfg.use_attn_result`` / 

371 ``cfg.use_split_qkv_input`` / ``cfg.use_hook_mlp_in``) is on, so an edge sweep 

372 must enable all three before caching or the writer- and reader-side hook 

373 points it needs never populate. 

374 

375 ``use_split_qkv_input`` is mutually exclusive with ``use_attn_in``, so a 

376 caller who arrives with ``use_attn_in`` on would otherwise trip the 

377 exclusivity error. This turns ``use_attn_in`` off before enabling the split 

378 input, and restores it after ``use_split_qkv_input`` has been turned back off. 

379 

380 Invariant: the caller's flag state (``use_attn_result``, 

381 ``use_split_qkv_input``, ``use_hook_mlp_in``, ``use_attn_in``) is unchanged on 

382 return, including when the body raises. Restoration runs in a ``finally`` 

383 block so a raise mid-sweep -- or in the caller's own later code -- cannot 

384 leave the per-head tensors materialized on the model. 

385 

386 Memory caveat: enabling ``use_attn_result``/``use_split_qkv_input`` makes 

387 every cached per-head tensor ``[batch, seq, n_heads, d_model]`` instead of 

388 the summed ``[batch, seq, d_model]`` residual. That is fine on a model the 

389 size of gpt2-small; it does not scale to models with many heads or layers. 

390 

391 Args: 

392 model: A ``TransformerBridge`` (or compatible) exposing ``cfg`` and 

393 ``set_use_attn_result``/``set_use_split_qkv_input``/ 

394 ``set_use_hook_mlp_in``/``set_use_attn_in``. 

395 """ 

396 cfg = model.cfg 

397 saved_attn_result = cfg.use_attn_result 

398 saved_split_qkv_input = cfg.use_split_qkv_input 

399 saved_hook_mlp_in = cfg.use_hook_mlp_in 

400 saved_attn_in = bool(getattr(cfg, "use_attn_in", False)) 

401 try: 

402 # use_attn_in and use_split_qkv_input are mutually exclusive; clear 

403 # use_attn_in first so enabling the split input cannot raise. 

404 if saved_attn_in: 

405 model.set_use_attn_in(False) 

406 if not cfg.use_attn_result: 

407 model.set_use_attn_result(True) 

408 if not cfg.use_split_qkv_input: 

409 model.set_use_split_qkv_input(True) 

410 if not cfg.use_hook_mlp_in: 

411 model.set_use_hook_mlp_in(True) 

412 yield 

413 finally: 

414 model.set_use_attn_result(saved_attn_result) 

415 model.set_use_split_qkv_input(saved_split_qkv_input) 

416 model.set_use_hook_mlp_in(saved_hook_mlp_in) 

417 # Re-enabling use_attn_in requires use_split_qkv_input already off; the 

418 # line above restored it, and a caller with use_attn_in on cannot also 

419 # have had use_split_qkv_input on, so this cannot trip the exclusivity. 

420 if saved_attn_in: 

421 model.set_use_attn_in(True) 

422 

423 

424def _check_required_hooks( 

425 cache: GradientCache, required_names: Sequence[str], graph: str, hint: str 

426) -> None: 

427 """Raise if any of ``required_names`` is absent from ``cache.activations``. 

428 

429 Shared by every granularity's graph-construction step, so a cache built 

430 with too narrow a ``names_filter`` -- or one produced while a required 

431 Bridge flag was off -- fails loudly instead of silently producing a 

432 truncated graph. 

433 """ 

434 missing = [name for name in required_names if name not in cache.activations] 

435 if missing: 

436 raise ValueError( 

437 f"{graph} requires hook points missing from the cache: " 

438 + ", ".join(missing) 

439 + f". {hint}" 

440 ) 

441 

442 

443def enumerate_nodes(model: Any, cache: GradientCache) -> list[Node]: 

444 """Enumerate the full node-granularity graph from the Bridge hook graph. 

445 

446 The graph is *explicit*: for ``n_layers`` layers it always contains the embed 

447 write, every attention head's output, and every layer's MLP output, at every 

448 sequence position. Sequence length and head count are read from the cached 

449 tensor shapes; ``n_layers`` from ``model.cfg``. 

450 

451 Args: 

452 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``. 

453 cache: A :class:`GradientCache` holding at least the required hook points. 

454 

455 Returns: 

456 The node list, ordered embed-then-layerwise for deterministic ranking. 

457 

458 Raises: 

459 ValueError: if any required hook point is absent from ``cache`` — the 

460 graph is never silently truncated. 

461 """ 

462 n_layers = int(model.cfg.n_layers) 

463 _check_required_hooks( 

464 cache, 

465 _required_hook_names(n_layers), 

466 "node graph", 

467 "Cache with a names_filter that keeps hook_embed, blocks.*.attn.hook_z, " 

468 "and blocks.*.hook_mlp_out.", 

469 ) 

470 

471 seq_len = cache.activations["hook_embed"].shape[1] 

472 

473 nodes: list[Node] = [Node(kind="embed", position=position) for position in range(seq_len)] 

474 for layer in range(n_layers): 

475 n_heads = cache.activations[f"blocks.{layer}.attn.hook_z"].shape[2] 

476 for position in range(seq_len): 

477 for head in range(n_heads): 

478 nodes.append(Node(kind="attn_head_out", layer=layer, head=head, position=position)) 

479 nodes.append(Node(kind="mlp_out", layer=layer, position=position)) 

480 return nodes 

481 

482 

483def _assert_edges_unique(edges: Sequence[tuple[Node, Node]]) -> None: 

484 """Raise if any writer -> reader pair appears more than once in ``edges``. 

485 

486 A writer feeding two distinct readers (a head's output feeding both the 

487 next layer's attention input and this layer's MLP input, say) is two 

488 edges; this guards the enumeration against a construction bug that 

489 collapses or duplicates a single ``(writer, reader)`` pair instead. 

490 """ 

491 seen: set[tuple[Node, Node]] = set() 

492 for edge in edges: 

493 if edge in seen: 

494 raise ValueError(f"edge {edge} enumerated more than once") 

495 seen.add(edge) 

496 

497 

498def _required_edge_reader_hook_names(n_layers: int) -> list[str]: 

499 """The reader hook points edge enumeration additionally requires. 

500 

501 ``_required_hook_names(..., granularity="edge")`` covers the per-head 

502 attention hooks a writer/reader pair into or out of a head needs. Edge 

503 enumeration reads two reader points those miss: 

504 

505 - each layer's MLP entry, ``attn.hook_mlp_in``'s layer-level sibling 

506 ``hook_mlp_in``, gated on ``cfg.use_hook_mlp_in`` the same way the per-head 

507 hooks are gated on their own flags, and 

508 - the terminal ``blocks.{n_layers-1}.hook_resid_post``, where the logits 

509 reader takes its gradient. This final residual hook fires unconditionally, 

510 so no Bridge flag gates it. 

511 """ 

512 names = [f"blocks.{layer}.hook_mlp_in" for layer in range(n_layers)] 

513 names.append(f"blocks.{n_layers - 1}.hook_resid_post") 

514 return names 

515 

516 

517def _edge_hook_names(n_layers: int) -> list[str]: 

518 """Every hook point an edge-granularity sweep must cache. 

519 

520 The per-head attention hooks from ``_required_hook_names(..., granularity="edge")`` 

521 plus each layer's MLP-entry reader hook and the terminal logits reader hook. 

522 """ 

523 return _required_hook_names(n_layers, granularity="edge") + _required_edge_reader_hook_names( 

524 n_layers 

525 ) 

526 

527 

528# Pre-norm blocks measured at most 2.7e-3 (gpt2, pythia-70m and Qwen2.5-0.5B in bfloat16, CPU 

529# and MPS; float32 stays below 1e-7); post-norm blocks measured 0.05 to 19. 

530_ATTN_WRITE_RTOL = 2.5e-2 

531 

532 

533def _attn_write_hook_names(n_layers: int) -> list[str]: 

534 """Each layer's attention residual write, cached only to check it against the head sum.""" 

535 return [f"blocks.{layer}.hook_attn_out" for layer in range(n_layers)] 

536 

537 

538_EDGE_UNSUPPORTED = ( 

539 "edge granularity and faithfulness are unsupported here. Node granularity " 

540 '(EdgeAttributionConfig(granularity="node")) is available.' 

541) 

542 

543 

544def _non_additive_attention_error(detail: str) -> ValueError: 

545 return ValueError( 

546 "per-head attention outputs are normalized or scaled before the residual add on this " 

547 f"architecture ({detail}), so attn.hook_result is not a head's residual contribution: " 

548 f"{_EDGE_UNSUPPORTED}" 

549 ) 

550 

551 

552def _attention_write_obstruction( 

553 model: Any, layers: Optional[Collection[int]] = None 

554) -> Optional[str]: 

555 """Describe the first block declaring a norm, scale or parallel mixer after its head sum.""" 

556 for layer, block in enumerate(getattr(model, "blocks", ())): 

557 if layers is not None and layer not in layers: 

558 continue 

559 submodules = getattr(block, "submodules", None) 

560 if not isinstance(submodules, dict) or "attn" not in submodules: 

561 continue 

562 scale = getattr(block, "residual_contribution_scale", 1.0) 

563 if scale != 1.0: 

564 return f"blocks.{layer} scales its attention output by {scale}" 

565 # Falcon-H1 scales attention and adds a parallel Mamba output inline, after 

566 # attn.hook_out, so the cached head-sum check cannot see either. 

567 mixers = [name for name in VARIANT_SUBMODULE_NAMES if name != "attn" and name in submodules] 

568 if mixers: 

569 return f"blocks.{layer} shares that add with a parallel {mixers[0]} mixer" 

570 aliases = getattr(block, "hook_aliases", None) 

571 target = aliases.get("hook_attn_out") if isinstance(aliases, dict) else None 

572 candidates = [target] if isinstance(target, str) else list(target or []) 

573 # A fallback chain resolves to its first target whose owner the block declares. 

574 for candidate in candidates: 

575 owner = candidate.split(".", 1)[0] 

576 if owner not in submodules: 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true

577 continue 

578 if owner != "attn": 

579 return f"blocks.{layer}.hook_attn_out reads {candidate}, after the head sum" 

580 break 

581 return None 

582 

583 

584def _check_attention_write_structure(model: Any) -> None: 

585 """Refuse blocks that declare a norm, scale or parallel mixer between head sum and residual.""" 

586 detail = _attention_write_obstruction(model) 

587 if detail is not None: 

588 raise _non_additive_attention_error(detail) 

589 

590 

591def _check_kv_heads_match_query_heads(model: Any) -> None: 

592 """Refuse GQA/MQA attention layers, whose K/V reader inputs are shared across query heads.""" 

593 cfg = model.cfg 

594 blocks = list(getattr(model, "blocks", ())) 

595 scalar_kv = getattr(cfg, "n_key_value_heads", None) 

596 per_layer_kv = getattr(cfg, "per_layer_num_key_value_heads", None) or [] 

597 for layer in range(cfg.n_layers): 

598 submodules = getattr(blocks[layer], "submodules", None) if layer < len(blocks) else None 

599 # A hybrid's SSM or linear-attention block inherits the scalar count but has no K/V heads. 

600 if isinstance(submodules, dict) and "attn" not in submodules: 

601 continue 

602 kv = per_layer_kv[layer] if layer < len(per_layer_kv) else None 

603 if kv is None: 

604 kv = scalar_kv 

605 if kv is not None and kv != cfg.n_heads: 

606 raise ValueError( 

607 f"blocks.{layer} has n_key_value_heads={kv} for n_heads={cfg.n_heads} (GQA/MQA), " 

608 f"so its K/V inputs are not per query head: {_EDGE_UNSUPPORTED}" 

609 ) 

610 

611 

612def _check_attention_write_is_head_sum(cache: GradientCache, n_layers: int) -> None: 

613 """Refuse a layer whose ``hook_attn_out`` is not its head-result sum plus a constant.""" 

614 for layer in range(n_layers): 

615 per_head = cache.activations.get(f"blocks.{layer}.attn.hook_result") 

616 written = cache.activations.get(f"blocks.{layer}.hook_attn_out") 

617 if per_head is None or written is None: 

618 continue 

619 work_dtype = torch.promote_types(written.dtype, torch.float32) 

620 written = written.to(work_dtype) 

621 head_sum = per_head.sum(dim=2, dtype=work_dtype) 

622 if head_sum.shape != written.shape: 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true

623 raise _non_additive_attention_error( 

624 f"blocks.{layer}.hook_attn_out has shape {tuple(written.shape)}, the summed " 

625 f"attn.hook_result {tuple(head_sum.shape)}" 

626 ) 

627 # The output bias belongs to no head and cancels in every clean-minus-corrupt delta, 

628 # so only the position-varying part of the remainder breaks additivity. 

629 remainder = written - head_sum 

630 remainder = remainder - remainder.mean(dim=(0, 1), keepdim=True) 

631 scale = written.norm().clamp_min(torch.finfo(work_dtype).tiny) 

632 relative = float(remainder.norm() / scale) 

633 if relative > _ATTN_WRITE_RTOL: 

634 raise _non_additive_attention_error( 

635 f"blocks.{layer}.hook_attn_out differs from the summed attn.hook_result by " 

636 f"{relative:.2g} of its norm" 

637 ) 

638 

639 

640def _writer_hook_names(n_layers: int) -> list[str]: 

641 """Hook points holding each writer's residual contribution: embed, head result, MLP out.""" 

642 names = ["hook_embed"] 

643 for layer in range(n_layers): 

644 names.append(f"blocks.{layer}.attn.hook_result") 

645 names.append(f"blocks.{layer}.hook_mlp_out") 

646 return names 

647 

648 

649def _reader_hook_names(n_layers: int) -> list[str]: 

650 """Hook points holding each reader's input; the logits reader's is listed separately.""" 

651 names: list[str] = [] 

652 for layer in range(n_layers): 

653 names.append(f"blocks.{layer}.attn.hook_q_input") 

654 names.append(f"blocks.{layer}.attn.hook_k_input") 

655 names.append(f"blocks.{layer}.attn.hook_v_input") 

656 names.append(f"blocks.{layer}.hook_mlp_in") 

657 return names 

658 

659 

660def enumerate_edges(model: Any, cache: GradientCache) -> list[tuple[Node, Node]]: 

661 """Enumerate every writer -> reader edge in the residual-stream graph. 

662 

663 At a fixed sequence position, the residual stream is a running sum: a 

664 reader (a head's split Q/K/V input, a layer's MLP entry, or the terminal 

665 logits readout) is fed by every writer (the embed write, every attention 

666 head's output, every layer's MLP output) that precedes it. Building the 

667 graph position-by-position tracks which writers are "available" so far and 

668 connects each new reader to all of them, then adds that layer's writers to 

669 the available set before moving on -- so a writer never edges to a reader 

670 upstream of it, and a writer feeding both a direct edge and a through-MLP 

671 edge produces two distinct ``(u, v)`` pairs rather than one summed together. 

672 

673 A terminal ``logits`` reader (read at the final ``hook_resid_post``) closes 

674 the graph: after the per-layer loop every remaining writer -- including the 

675 final layer's MLP output, which no per-layer reader sees -- edges to it. That 

676 edge carries the writer's direct skip-connection contribution to the metric, 

677 so a writer's aggregate over its outgoing edges equals its direct node score. 

678 

679 On ``cfg.parallel_attn_mlp`` models (Pythia, GPT-J, Falcon, Phi) the MLP 

680 reads the layer input, not the post-attention residual, so a layer's own 

681 heads are not writers into that layer's MLP; those same-layer head->mlp_in 

682 edges are dropped while the heads still feed later readers and the logits 

683 reader. 

684 

685 Args: 

686 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers`` 

687 and, optionally, ``cfg.parallel_attn_mlp``. 

688 cache: A :class:`GradientCache` holding at least the required hook points 

689 for edge granularity. 

690 

691 Returns: 

692 The edge list as ``(writer, reader)`` node pairs; no pair repeats. 

693 

694 Raises: 

695 ValueError: if any required hook point is absent from ``cache`` — the 

696 graph is never silently truncated. 

697 """ 

698 n_layers = int(model.cfg.n_layers) 

699 _check_required_hooks( 

700 cache, 

701 _edge_hook_names(n_layers), 

702 "edge graph", 

703 "Cache with a names_filter that keeps the edge-granularity hook set: " 

704 "hook_embed, blocks.*.attn.hook_z, blocks.*.hook_mlp_out, " 

705 "blocks.*.attn.hook_result, blocks.*.attn.hook_q_input, " 

706 "blocks.*.attn.hook_k_input, blocks.*.attn.hook_v_input, " 

707 "blocks.*.hook_mlp_in, and blocks.{n_layers-1}.hook_resid_post.", 

708 ) 

709 

710 parallel_attn_mlp = bool(getattr(model.cfg, "parallel_attn_mlp", False)) 

711 

712 seq_len = cache.activations["hook_embed"].shape[1] 

713 edges: list[tuple[Node, Node]] = [] 

714 

715 for position in range(seq_len): 

716 available: list[Node] = [Node(kind="embed", position=position)] 

717 for layer in range(n_layers): 

718 n_heads = cache.activations[f"blocks.{layer}.attn.hook_z"].shape[2] 

719 

720 attn_reader_kinds: tuple[NodeKind, NodeKind, NodeKind] = ( 

721 "q_input", 

722 "k_input", 

723 "v_input", 

724 ) 

725 attn_readers = [ 

726 Node(kind=kind, layer=layer, head=head, position=position) 

727 for kind in attn_reader_kinds 

728 for head in range(n_heads) 

729 ] 

730 for reader in attn_readers: 

731 edges.extend((writer, reader) for writer in available) 

732 

733 layer_heads = [ 

734 Node(kind="attn_head_out", layer=layer, head=head, position=position) 

735 for head in range(n_heads) 

736 ] 

737 

738 mlp_reader = Node(kind="mlp_in", layer=layer, position=position) 

739 if parallel_attn_mlp: 

740 # The MLP reads the layer input, not the post-attention residual, 

741 # so this layer's heads are not writers into its MLP. They still 

742 # become available to later readers and the logits reader. 

743 edges.extend((writer, mlp_reader) for writer in available) 

744 available = available + layer_heads 

745 else: 

746 available = available + layer_heads 

747 edges.extend((writer, mlp_reader) for writer in available) 

748 

749 available = available + [Node(kind="mlp_out", layer=layer, position=position)] 

750 

751 logits_reader = Node(kind="logits", layer=n_layers - 1, position=position) 

752 edges.extend((writer, logits_reader) for writer in available) 

753 

754 _assert_edges_unique(edges) 

755 return edges 

756 

757 

758def _as_predicate(names_filter: NamesFilter) -> Callable[[str], bool]: 

759 if names_filter is None: 759 ↛ 760line 759 didn't jump to line 760 because the condition on line 759 was never true

760 return lambda name: True 

761 if isinstance(names_filter, str): 761 ↛ 762line 761 didn't jump to line 762 because the condition on line 761 was never true

762 target = names_filter 

763 return lambda name: name == target 

764 if callable(names_filter): 764 ↛ 765line 764 didn't jump to line 765 because the condition on line 764 was never true

765 return names_filter 

766 wanted = set(names_filter) 

767 return lambda name: name in wanted 

768 

769 

770def cache_activation_and_gradient( 

771 model: Any, 

772 tokens: torch.Tensor, 

773 metric_fn: MetricFn, 

774 names_filter: NamesFilter = None, 

775 compute_gradient: bool = True, 

776) -> GradientCache: 

777 """Run one forward and capture activations plus (optionally) their metric-gradients. 

778 

779 Registers a forward hook and a backward hook at each cached point, runs one 

780 grad-enabled forward, then drives a single backward with 

781 :func:`torch.autograd.grad` to fire the backward hooks. 

782 ``run_with_cache(..., incl_bwd=True)`` only backpropagates the model's own 

783 scalar output, so a custom (non-scalar-output) metric such as a logit-diff 

784 needs the backward driven here. 

785 

786 Gradients come from the backward hooks, not from ``.grad`` on the cached 

787 tensors. ``TransformerBridge`` reshapes the tensor handed to a forward hook at 

788 a converted point (``attn.hook_z``, ``hook_q/k/v``, ``hook_attn_out``), so that 

789 tensor is a view the model's forward never consumes: ``retain_grad()`` on it is 

790 inert and its ``.grad`` stays ``None``. A backward hook goes through the same 

791 conversion and delivers the real gradient in canonical shape. Driving the 

792 backward with :func:`torch.autograd.grad` instead of ``metric.backward()`` 

793 keeps it off every parameter's ``.grad`` buffer — no caller grads clobbered, no 

794 model-sized buffer allocated. 

795 

796 With ``compute_gradient=False`` the backward is skipped entirely: only forward 

797 hooks are registered, no backward is driven, and every cached point's gradient 

798 is ``None``. Callers that read activations only (the clean pass of 

799 :func:`attribution_patch`, which pairs these with the *corrupt* run's gradients) 

800 use this to run a plain forward instead of a needless forward + backward. 

801 

802 Args: 

803 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``, 

804 ``hook_dict``, and the ``hooks()`` context manager. 

805 tokens: Input token ids for a single forward pass. 

806 metric_fn: Maps the model logits to a scalar to differentiate. 

807 names_filter: Restricts which hook points are cached (and have gradients 

808 retained). ``None`` (the default) caches the node-granularity hook set — 

809 ``hook_embed`` plus each layer's ``attn.hook_z`` and ``hook_mlp_out``; 

810 pass an explicit filter to cache any hook point outside that set. On a 

811 real Bridge, ``None`` cannot mean "every hook point": the gated points 

812 (``hook_mlp_in``, ``attn.hook_result``, the split-QKV inputs) that 

813 ``hook_dict`` exposes raise in ``add_hook`` unless their ``set_use_*`` 

814 flag is on. 

815 compute_gradient: When ``True`` (default) capture gradients via backward 

816 hooks. When ``False`` run an activation-only forward and leave every 

817 gradient ``None``. 

818 

819 Returns: 

820 A :class:`GradientCache` with per-hook activations, and gradients when 

821 ``compute_gradient`` is ``True`` (all ``None`` otherwise). 

822 """ 

823 if compute_gradient and not torch.is_grad_enabled(): 

824 raise ValueError( 

825 "cache_activation_and_gradient needs autograd, but gradient tracking " 

826 "is off (torch.no_grad(), set_grad_enabled(False), or inference mode)." 

827 ) 

828 

829 if names_filter is None: 

830 # Default to the node-granularity hook set rather than every hook point: a 

831 # real Bridge's hook_dict holds gated points (hook_mlp_in, attn.hook_result, 

832 # the split-QKV inputs) that add_hook rejects unless their set_use_* flag is 

833 # on, so caching "everything" raises. Pass an explicit filter to reach them. 

834 names_filter = _required_hook_names(int(model.cfg.n_layers)) 

835 predicate = _as_predicate(names_filter) 

836 names = [name for name in model.hook_dict if predicate(name)] 

837 if not names: 837 ↛ 838line 837 didn't jump to line 838 because the condition on line 837 was never true

838 raise ValueError("names_filter matched no hook points") 

839 

840 live: dict[str, torch.Tensor] = {} 

841 activations: dict[str, torch.Tensor] = {} 

842 gradients: dict[str, Optional[torch.Tensor]] = {} 

843 

844 def make_fwd_hook(name: str) -> Callable[..., None]: 

845 def hook(tensor: torch.Tensor, *, hook: Any) -> None: 

846 del hook 

847 if isinstance(tensor, torch.Tensor): 847 ↛ 850line 847 didn't jump to line 850 because the condition on line 847 was always true

848 live[name] = tensor 

849 activations[name] = tensor.detach().clone() 

850 return None 

851 

852 return hook 

853 

854 def make_bwd_hook(name: str) -> Callable[..., None]: 

855 def hook(grad: torch.Tensor, *, hook: Any) -> None: 

856 del hook 

857 if isinstance(grad, torch.Tensor): 857 ↛ 859line 857 didn't jump to line 859 because the condition on line 857 was always true

858 gradients[name] = grad.detach().clone() 

859 return None 

860 

861 return hook 

862 

863 fwd_hooks = [(name, make_fwd_hook(name)) for name in names] 

864 

865 if not compute_gradient: 

866 with model.hooks(fwd_hooks=fwd_hooks): 

867 logits = model(tokens) 

868 metric = metric_fn(logits) 

869 if metric.dim() != 0: 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true

870 raise ValueError( 

871 f"metric_fn must return a scalar tensor, got shape {tuple(metric.shape)}" 

872 ) 

873 return GradientCache( 

874 activations=activations, 

875 gradients={name: None for name in activations}, 

876 metric=metric.detach(), 

877 ) 

878 

879 bwd_hooks = [(name, make_bwd_hook(name)) for name in names] 

880 

881 with model.hooks(fwd_hooks=fwd_hooks, bwd_hooks=bwd_hooks): 

882 logits = model(tokens) 

883 metric = metric_fn(logits) 

884 if metric.dim() != 0: 884 ↛ 885line 884 didn't jump to line 885 because the condition on line 884 was never true

885 raise ValueError( 

886 f"metric_fn must return a scalar tensor, got shape {tuple(metric.shape)}" 

887 ) 

888 captured_names = [name for name in names if name in live] 

889 # torch.autograd.grad only *drives* the backward; the gradients we keep are 

890 # the converted-shape ones the backward hooks write into `gradients`. Unlike 

891 # metric.backward() it touches no parameter `.grad` buffer. 

892 returned = ( 

893 torch.autograd.grad( 

894 metric, 

895 inputs=[live[name] for name in captured_names], 

896 allow_unused=True, 

897 retain_graph=False, 

898 ) 

899 if captured_names 

900 else () 

901 ) 

902 

903 # The most-upstream cached point's own backward hook never fires — nothing 

904 # requested is upstream of it, so the backward stops at it — but its cached 

905 # tensor is on-path and unconverted, so torch.autograd.grad returns that 

906 # gradient directly. Backfill any point the hooks missed from that return. 

907 for name, grad in zip(captured_names, returned): 

908 if gradients.get(name) is None: 

909 gradients[name] = None if grad is None else grad.detach().clone() 

910 

911 return GradientCache(activations=activations, gradients=gradients, metric=metric.detach()) 

912 

913 

914def capture_writer_outputs( 

915 model: Any, 

916 tokens: torch.Tensor, 

917 names_filter: NamesFilter = None, 

918) -> dict[str, torch.Tensor]: 

919 """Capture writer contributions in one forward-only pass, with no gradient retained. 

920 

921 Memory caveat: ``attn.hook_result`` is a per-head ``[batch, seq, n_heads, d_model]`` 

922 tensor, so this does not scale to models with many heads or layers. 

923 

924 Args: 

925 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``, 

926 ``hook_dict``, and the ``hooks()`` context manager. 

927 tokens: Input token ids for a single forward pass. 

928 names_filter: Restricts which hook points are captured. ``None`` (the 

929 default) captures ``hook_embed``, ``attn.hook_result`` and 

930 ``hook_mlp_out``. Gated points (``attn.hook_result``, the split-QKV 

931 inputs, ``hook_mlp_in``) raise in ``add_hook`` unless their 

932 ``set_use_*`` flag is on; see :func:`_edge_hook_flags`. 

933 

934 Returns: 

935 Detached activation tensors keyed by hook name, for every hook point 

936 matching ``names_filter``. 

937 

938 Raises: 

939 ValueError: if ``names_filter`` matches no hook point. 

940 """ 

941 if names_filter is None: 

942 names_filter = _writer_hook_names(int(model.cfg.n_layers)) 

943 predicate = _as_predicate(names_filter) 

944 names = [name for name in model.hook_dict if predicate(name)] 

945 if not names: 

946 raise ValueError("names_filter matched no hook points") 

947 

948 captured: dict[str, torch.Tensor] = {} 

949 

950 def make_fwd_hook(name: str) -> Callable[..., None]: 

951 def hook(tensor: torch.Tensor, *, hook: Any) -> None: 

952 del hook 

953 if isinstance(tensor, torch.Tensor): 953 ↛ 955line 953 didn't jump to line 955 because the condition on line 953 was always true

954 captured[name] = tensor.detach().clone() 

955 return None 

956 

957 return hook 

958 

959 fwd_hooks = [(name, make_fwd_hook(name)) for name in names] 

960 with torch.no_grad(), model.hooks(fwd_hooks=fwd_hooks): 

961 model(tokens) 

962 

963 return captured 

964 

965 

966def _node_effects( 

967 clean_cache: GradientCache, 

968 corrupt_cache: GradientCache, 

969 nodes: Sequence[Node], 

970) -> dict[Node, float]: 

971 """Score every node with the first-order attribution ``(a_clean - a_corrupt) . g``. 

972 

973 The gradient ``g`` is taken from the corrupt cache (denoising convention). For 

974 each node the feature dimension (``d_model``, or ``d_head`` for an attention 

975 head) is contracted at the node's position/head, giving one signed scalar. 

976 """ 

977 scores: dict[Node, float] = {} 

978 for node in nodes: 

979 name = node.hook_name 

980 grad = corrupt_cache.gradients.get(name) 

981 if grad is None: 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true

982 raise ValueError( 

983 f"node {node} reads {name!r}, but the corrupt cache holds no gradient " 

984 "there; cache with a names_filter that retains this hook point." 

985 ) 

986 delta = clean_cache.activations[name] - corrupt_cache.activations[name] 

987 contribution = delta * grad 

988 if node.kind == "attn_head_out": 

989 value = contribution[0, node.position, node.head].sum() 

990 else: 

991 value = contribution[0, node.position].sum() 

992 scores[node] = float(value) 

993 return scores 

994 

995 

996def _writer_hook_name(node: Node) -> str: 

997 """The residual-stream hook point holding a writer node's own contribution. 

998 

999 Distinct from ``Node.hook_name``: an ``attn_head_out`` node's ``hook_name`` 

1000 resolves to ``attn.hook_z``, the pre-``hook_result`` value node granularity 

1001 scores. An edge's writer contribution must instead be measured in the same 

1002 ``d_model`` space a reader's gradient lives in, which is ``attn.hook_result`` 

1003 -- the per-head decomposition of the head's contribution after it is 

1004 projected into the residual stream. 

1005 """ 

1006 if node.kind == "embed": 

1007 return "hook_embed" 

1008 if node.kind == "attn_head_out": 

1009 return f"blocks.{node.layer}.attn.hook_result" 

1010 if node.kind == "mlp_out": 1010 ↛ 1012line 1010 didn't jump to line 1012 because the condition on line 1010 was always true

1011 return f"blocks.{node.layer}.hook_mlp_out" 

1012 raise ValueError(f"{node.kind} is a reader kind and has no writer contribution") 

1013 

1014 

1015def _edge_effects( 

1016 clean_cache: GradientCache, 

1017 corrupt_cache: GradientCache, 

1018 edges: Sequence[tuple[Node, Node]], 

1019) -> dict[tuple[Node, Node], float]: 

1020 """Score every edge with ``(a_clean[u] - a_corrupt[u]) . d(metric)/d(input of v)``. 

1021 

1022 Mirrors ``_node_effects``: the delta is the writer's own residual 

1023 contribution (clean minus corrupt cache), dotted with the reader's 

1024 corrupt-run gradient -- the same denoising convention ``_node_effects`` 

1025 uses. Unlike a node score, the delta and the gradient are read from two 

1026 different hook points (the writer's and the reader's), since an edge 

1027 measures how much of one component's output reaches another component's 

1028 input. A ``logits`` reader takes its gradient at the final 

1029 ``hook_resid_post`` and contracts over ``d_model`` at its position, the same 

1030 shape path as an ``mlp_in`` reader. 

1031 

1032 Two structural facts keep this off a per-edge recompute: a writer hook's 

1033 clean-minus-corrupt delta is the same tensor for every edge that writer 

1034 feeds, and every edge into one reader shares that reader's gradient vector. 

1035 Each writer delta is therefore computed once, and each reader's incoming 

1036 edges are scored with a single batched matrix-vector product over the 

1037 stacked writer deltas -- reducing to the same per-edge scalars a 

1038 ``float((delta_vec * grad_vec).sum())`` per edge would, reader gradient 

1039 reused across all of that reader's edges. 

1040 """ 

1041 scores: dict[tuple[Node, Node], float] = {} 

1042 writer_deltas: dict[str, torch.Tensor] = {} 

1043 

1044 def writer_delta_vec(writer: Node) -> torch.Tensor: 

1045 name = _writer_hook_name(writer) 

1046 delta = writer_deltas.get(name) 

1047 if delta is None: 

1048 delta = clean_cache.activations[name] - corrupt_cache.activations[name] 

1049 writer_deltas[name] = delta 

1050 if writer.kind == "attn_head_out": 

1051 return delta[0, writer.position, writer.head] 

1052 return delta[0, writer.position] 

1053 

1054 writers_by_reader: dict[Node, list[Node]] = {} 

1055 reader_order: list[Node] = [] 

1056 for writer, reader in edges: 

1057 bucket = writers_by_reader.get(reader) 

1058 if bucket is None: 

1059 writers_by_reader[reader] = bucket = [] 

1060 reader_order.append(reader) 

1061 bucket.append(writer) 

1062 

1063 for reader in reader_order: 

1064 reader_name = reader.hook_name 

1065 grad = corrupt_cache.gradients.get(reader_name) 

1066 writers = writers_by_reader[reader] 

1067 if grad is None: 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true

1068 raise ValueError( 

1069 f"edge {(writers[0], reader)} reads its gradient at {reader_name!r}, but " 

1070 "the corrupt cache holds none there; cache with a names_filter that " 

1071 "retains this hook point." 

1072 ) 

1073 if reader.kind in ("q_input", "k_input", "v_input"): 

1074 grad_vec = grad[0, reader.position, reader.head] 

1075 else: 

1076 grad_vec = grad[0, reader.position] 

1077 delta_matrix = torch.stack([writer_delta_vec(writer) for writer in writers]) 

1078 edge_values = delta_matrix @ grad_vec 

1079 for writer, value in zip(writers, edge_values.tolist()): 

1080 scores[(writer, reader)] = value 

1081 return scores 

1082 

1083 

1084def _aggregate_edge_scores_to_writer_nodes( 

1085 edge_scores: dict[tuple[Node, Node], float], 

1086) -> dict[Node, float]: 

1087 """Sum each writer's outgoing edge scores into that writer's aggregate node score. 

1088 

1089 A writer's aggregate is the sum of its effects along every edge it feeds. 

1090 enumerate_edges gives every writer an edge to the terminal logits reader, so 

1091 the aggregate includes the writer's direct skip-connection contribution to 

1092 the metric -- the part of its residual-stream write that no intermediate 

1093 component reads, only the final readout does -- and therefore equals the 

1094 quantity a node-granularity sweep measures directly at the writer's own hook 

1095 point. Every writer has at least that one outgoing edge, including the final 

1096 layer's MLP output, whose only reader is the logits terminal, so no writer is 

1097 missing from the aggregate. 

1098 """ 

1099 totals: dict[Node, float] = {} 

1100 for (writer, _reader), score in edge_scores.items(): 

1101 totals[writer] = totals.get(writer, 0.0) + score 

1102 return totals 

1103 

1104 

1105def _writer_contribution_slice(writer: Node, tensor: torch.Tensor) -> torch.Tensor: 

1106 """The writer's ``d_model`` contribution at its position, and head if it has one.""" 

1107 if writer.kind == "attn_head_out": 

1108 return tensor[0, writer.position, writer.head] 

1109 return tensor[0, writer.position] 

1110 

1111 

1112def _excluded_writers_by_reader( 

1113 edges: Sequence[tuple[Node, Node]], 

1114 circuit: Collection[tuple[Node, Node]], 

1115) -> dict[Node, list[Node]]: 

1116 """Group the out-of-circuit writers feeding each reader node, so each gets one hook.""" 

1117 circuit_set = set(circuit) 

1118 excluded: dict[Node, list[Node]] = {} 

1119 for writer, reader in edges: 

1120 if (writer, reader) not in circuit_set: 

1121 excluded.setdefault(reader, []).append(writer) 

1122 return excluded 

1123 

1124 

1125def _make_writer_capture_hook(name: str, sink: dict[str, torch.Tensor]) -> Callable[..., None]: 

1126 """Record a writer's live contribution for the reader hooks that follow it. 

1127 

1128 Detached, not cloned: it is only read, and the reader hook clones its own input. 

1129 """ 

1130 

1131 def hook(tensor: torch.Tensor, *, hook: Any) -> None: 

1132 del hook 

1133 if isinstance(tensor, torch.Tensor): 1133 ↛ 1135line 1133 didn't jump to line 1135 because the condition on line 1133 was always true

1134 sink[name] = tensor.detach() 

1135 return None 

1136 

1137 return hook 

1138 

1139 

1140def _make_edge_ablation_hook( 

1141 reader: Node, 

1142 excluded_writers: Sequence[Node], 

1143 live_writers: dict[str, torch.Tensor], 

1144 replacement_writers: dict[str, torch.Tensor], 

1145) -> Callable[..., torch.Tensor]: 

1146 """Rebuild one reader's input with its out-of-circuit writers replaced. 

1147 

1148 Exact, not approximate: the pre-norm residual is a plain sum of writer contributions. 

1149 """ 

1150 if reader.kind in ("q_input", "k_input", "v_input"): 

1151 # Node.__post_init__ guarantees a head for the per-head reader kinds. 

1152 index: tuple[int, ...] = (0, reader.position, cast(int, reader.head)) 

1153 else: 

1154 index = (0, reader.position) 

1155 

1156 def hook(tensor: torch.Tensor, *, hook: Any) -> torch.Tensor: 

1157 del hook 

1158 rebuilt = tensor.clone() 

1159 for writer in excluded_writers: 

1160 name = _writer_hook_name(writer) 

1161 live = live_writers.get(name) 

1162 replacement = replacement_writers.get(name) 

1163 if live is None or replacement is None: 

1164 raise ValueError( 

1165 f"cannot ablate the edge from {writer} into {reader}: no " 

1166 f"contribution captured at {name!r}. Enable the writer's hook " 

1167 "family before running the ablation." 

1168 ) 

1169 rebuilt[index] = ( 

1170 rebuilt[index] 

1171 - _writer_contribution_slice(writer, live) 

1172 + _writer_contribution_slice(writer, replacement) 

1173 ) 

1174 return rebuilt 

1175 

1176 return hook 

1177 

1178 

1179def _ablate_edges( 

1180 model: Any, 

1181 tokens: torch.Tensor, 

1182 edges: Sequence[tuple[Node, Node]], 

1183 circuit: Collection[tuple[Node, Node]], 

1184 replacement_writers: dict[str, torch.Tensor], 

1185) -> torch.Tensor: 

1186 """Return logits from one no-grad forward with every edge outside ``circuit`` ablated. 

1187 

1188 The caller must have the writer and reader hook families enabled (see :func:`_edge_hook_flags`). 

1189 """ 

1190 excluded_by_reader = _excluded_writers_by_reader(edges, circuit) 

1191 

1192 live_writers: dict[str, torch.Tensor] = {} 

1193 fwd_hooks: list[tuple[str, Callable[..., Any]]] = [ 

1194 (name, _make_writer_capture_hook(name, live_writers)) 

1195 for name in _writer_hook_names(int(model.cfg.n_layers)) 

1196 ] 

1197 fwd_hooks.extend( 

1198 ( 

1199 reader.hook_name, 

1200 _make_edge_ablation_hook(reader, writers, live_writers, replacement_writers), 

1201 ) 

1202 for reader, writers in excluded_by_reader.items() 

1203 ) 

1204 

1205 with torch.no_grad(), model.hooks(fwd_hooks=fwd_hooks): 

1206 logits = model(tokens) 

1207 return logits 

1208 

1209 

1210def _validate_pair( 

1211 clean: torch.Tensor, corrupt: torch.Tensor, *, operation: str, method: str 

1212) -> None: 

1213 """Require 2D clean/corrupt token tensors holding equal numbers of equal-length pairs.""" 

1214 if clean.ndim != 2 or corrupt.ndim != 2: 

1215 raise ValueError( 

1216 f"{operation} expects 2D [batch, seq] token tensors, got clean " 

1217 f"{tuple(clean.shape)} and corrupt {tuple(corrupt.shape)}" 

1218 ) 

1219 if clean.shape[0] != corrupt.shape[0]: 

1220 raise ValueError( 

1221 "clean and corrupt must hold the same number of prompt pairs, got " 

1222 f"{clean.shape[0]} and {corrupt.shape[0]}" 

1223 ) 

1224 if clean.shape[1] != corrupt.shape[1]: 

1225 raise ValueError( 

1226 "each clean/corrupt pair must tokenize to the same length; got clean " 

1227 f"length {clean.shape[1]} and corrupt length {corrupt.shape[1]}. " 

1228 f"{method} aligns activations position-by-position." 

1229 ) 

1230 

1231 

1232def attribution_patch( 

1233 model: Any, 

1234 clean: torch.Tensor, 

1235 corrupt: torch.Tensor, 

1236 metric_fn: MetricFn, 

1237 config: EdgeAttributionConfig = EdgeAttributionConfig(), 

1238) -> AttributionResult: 

1239 """Estimate every component's causal effect on ``metric_fn`` in two forwards + one backward. 

1240 

1241 For each clean/corrupt pair this runs a clean forward (for ``a_clean``) and a 

1242 corrupt forward whose backward hooks capture ``g = d(metric)/d(a)`` (for 

1243 ``a_corrupt`` and its gradient). At node granularity (``config.granularity == 

1244 "node"``) each node is scored with the first-order Taylor estimate 

1245 ``effect(node) = (a_clean - a_corrupt) . g``. At edge granularity 

1246 (``config.granularity == "edge"``) each writer -> reader edge is scored with 

1247 ``effect(edge) = (a_clean[writer] - a_corrupt[writer]) . d(metric)/d(input of 

1248 reader)``, and ``node_scores`` holds each writer's aggregate effect (the sum 

1249 of its outgoing edge scores). 

1250 

1251 Sign/direction convention (denoising form): gradients are taken on the *corrupt* 

1252 run and the estimate points *toward* the clean activation, so a positive score 

1253 means patching that node from corrupt toward clean moves the metric in the 

1254 positive direction. An oracle-parity test maps this convention onto a pinned 

1255 reference rather than assuming the two agree. 

1256 

1257 Dataset averaging: ``clean``/``corrupt`` may hold a batch of prompt pairs. Each 

1258 pair is scored independently (per-example forward/backward, so its own 

1259 reconstruction identity holds) and per-node (or per-edge) scores are averaged 

1260 across the batch before ranking. 

1261 

1262 The model and every submodule must be in evaluation mode. Separate clean and 

1263 corrupt forwards cannot produce meaningful activation differences if stochastic 

1264 training layers such as dropout remain active. 

1265 

1266 Args: 

1267 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``, 

1268 ``hook_dict``, and ``hooks()``. 

1269 clean: Clean token ids, shape ``[batch, seq]``. 

1270 corrupt: Corrupt token ids, shape ``[batch, seq]``, paired row-by-row with 

1271 ``clean``. 

1272 metric_fn: Maps single-example logits to a scalar to differentiate. 

1273 config: Sweep configuration. Node and edge granularity are both 

1274 supported with plain attribution (``ig_steps=1``); ``ig_steps>1`` 

1275 raises at construction. 

1276 

1277 Returns: 

1278 An :class:`AttributionResult` whose ``node_scores`` are averaged over the 

1279 batch. For an edge-granularity sweep, ``edge_scores`` is populated too and 

1280 ``node_scores`` is the per-writer aggregate of those edge scores. The 

1281 graph's terminal logits reader gives every writer an edge carrying its 

1282 direct skip-connection contribution to the metric, so this aggregate 

1283 equals the quantity a node-granularity sweep on the same model returns 

1284 (including for the final layer's MLP output, whose only outgoing edge is 

1285 the one to the logits reader). 

1286 

1287 Raises: 

1288 ValueError: if ``clean``/``corrupt`` are not 2D, hold a different number of 

1289 pairs, a pair tokenizes to different lengths (activations must align 

1290 position-by-position), or the model or one of its submodules is in 

1291 training mode. At edge granularity, also if per-head attention outputs 

1292 are normalized or scaled before the residual add (post-norm, 

1293 scaled-residual or parallel-mixer blocks), where ``attn.hook_result`` is not 

1294 a head's residual contribution, or if an attention layer's K/V head count 

1295 differs from ``n_heads`` (GQA/MQA). 

1296 """ 

1297 _validate_pair(clean, corrupt, operation="attribution_patch", method="Attribution patching") 

1298 require_eval_mode(model, operation="attribution_patch()") 

1299 

1300 batch = int(clean.shape[0]) 

1301 n_layers = int(model.cfg.n_layers) 

1302 

1303 if config.granularity == "edge": 

1304 _check_attention_write_structure(model) 

1305 _check_kv_heads_match_query_heads(model) 

1306 hook_names = _edge_hook_names(n_layers) 

1307 edge_totals: dict[tuple[Node, Node], float] = {} 

1308 

1309 # Scope the per-head hook-flag mutation to the caching loop so the 

1310 # caller's flag state is restored on both the normal and the error path. 

1311 with _edge_hook_flags(model): 

1312 for index in range(batch): 

1313 clean_cache = cache_activation_and_gradient( 

1314 model, 

1315 clean[index : index + 1], 

1316 metric_fn, 

1317 names_filter=hook_names + _attn_write_hook_names(n_layers), 

1318 compute_gradient=False, 

1319 ) 

1320 _check_attention_write_is_head_sum(clean_cache, n_layers) 

1321 corrupt_cache = cache_activation_and_gradient( 

1322 model, corrupt[index : index + 1], metric_fn, names_filter=hook_names 

1323 ) 

1324 edges = enumerate_edges(model, corrupt_cache) 

1325 for edge, score in _edge_effects(clean_cache, corrupt_cache, edges).items(): 

1326 edge_totals[edge] = edge_totals.get(edge, 0.0) + score 

1327 

1328 edge_scores = {edge: total / batch for edge, total in edge_totals.items()} 

1329 node_scores = _aggregate_edge_scores_to_writer_nodes(edge_scores) 

1330 return AttributionResult(node_scores=node_scores, edge_scores=edge_scores) 

1331 

1332 node_hook_names = _required_hook_names(n_layers) 

1333 totals: dict[Node, float] = {} 

1334 

1335 for index in range(batch): 

1336 clean_cache = cache_activation_and_gradient( 

1337 model, 

1338 clean[index : index + 1], 

1339 metric_fn, 

1340 names_filter=node_hook_names, 

1341 compute_gradient=False, 

1342 ) 

1343 corrupt_cache = cache_activation_and_gradient( 

1344 model, corrupt[index : index + 1], metric_fn, names_filter=node_hook_names 

1345 ) 

1346 nodes = enumerate_nodes(model, corrupt_cache) 

1347 for node, score in _node_effects(clean_cache, corrupt_cache, nodes).items(): 

1348 totals[node] = totals.get(node, 0.0) + score 

1349 

1350 node_scores = {node: total / batch for node, total in totals.items()} 

1351 return AttributionResult(node_scores=node_scores) 

1352 

1353 

1354def _normalize_circuit( 

1355 circuit: Sequence[tuple[Node, Node]] | Sequence[tuple[Node, Node, float]], 

1356) -> list[tuple[Node, Node]]: 

1357 """Deduplicate circuit pairs in first-occurrence order, dropping ``top_edges`` scores.""" 

1358 normalized: list[tuple[Node, Node]] = [] 

1359 seen: set[tuple[Node, Node]] = set() 

1360 for entry in circuit: 

1361 if len(entry) == 2: 

1362 writer, reader = entry 

1363 elif len(entry) == 3: 1363 ↛ 1366line 1363 didn't jump to line 1366 because the condition on line 1363 was always true

1364 writer, reader, _score = entry 

1365 else: 

1366 raise ValueError( 

1367 f"circuit entries must be (writer, reader) or (writer, reader, score), " 

1368 f"got a {len(entry)}-tuple" 

1369 ) 

1370 edge = (writer, reader) 

1371 if edge not in seen: 

1372 normalized.append(edge) 

1373 seen.add(edge) 

1374 return normalized 

1375 

1376 

1377def _edge_class(edge: tuple[Node, Node]) -> EdgeClass: 

1378 """The edge's class, keyed by what its reader consumes; Q and K share one (both feed scores).""" 

1379 reader = edge[1] 

1380 if reader.kind in ("q_input", "k_input"): 

1381 return "into_qk" 

1382 if reader.kind == "v_input": 

1383 return "into_v" 

1384 if reader.kind == "mlp_in": 

1385 return "into_mlp" 

1386 if reader.kind == "logits": 

1387 return "into_logits" 

1388 raise ValueError(f"{reader.kind} is a writer kind and has no edge class") 

1389 

1390 

1391def _mean_writer_contributions( 

1392 model: Any, 

1393 mean_tokens: torch.Tensor, 

1394) -> dict[str, torch.Tensor]: 

1395 """Average calibration examples, preserving position, head, and singleton batch axes.""" 

1396 count = int(mean_tokens.shape[0]) 

1397 if count == 0: 1397 ↛ 1398line 1397 didn't jump to line 1398 because the condition on line 1397 was never true

1398 raise ValueError("mean_tokens must contain at least one calibration example") 

1399 totals: dict[str, torch.Tensor] = {} 

1400 for index in range(count): 

1401 captured = capture_writer_outputs(model, mean_tokens[index : index + 1]) 

1402 for name, tensor in captured.items(): 

1403 running = totals.get(name) 

1404 totals[name] = tensor if running is None else running + tensor 

1405 return {name: total / count for name, total in totals.items()} 

1406 

1407 

1408def faithfulness( 

1409 model: Any, 

1410 clean: torch.Tensor, 

1411 corrupt: torch.Tensor, 

1412 metric_fn: MetricFn, 

1413 circuit: Sequence[tuple[Node, Node]] | Sequence[tuple[Node, Node, float]], 

1414 config: FaithfulnessConfig = FaithfulnessConfig(), 

1415 *, 

1416 mean_tokens: Optional[torch.Tensor] = None, 

1417) -> FaithfulnessResult: 

1418 """Measure how much of the clean-to-corrupt metric gap a circuit recovers. 

1419 

1420 Ablates every edge *outside* ``circuit`` (forward-only, exact rather than linearized) and 

1421 reports the metric the model then produces. Feed a ranked edge set straight in from 

1422 ``attribution_patch(..., config=EdgeAttributionConfig(granularity="edge")).top_edges(k=...)``. 

1423 

1424 Under mean ablation recovery is still normalized to the clean/corrupt gap, so an empty 

1425 circuit need not have zero recovery. 

1426 

1427 Args: 

1428 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``, 

1429 ``hook_dict``, and ``hooks()``. 

1430 clean: Clean token ids for one evaluated example, shape ``[1, seq]``. 

1431 corrupt: Paired corrupt token ids, shape ``[1, seq]``. 

1432 metric_fn: Maps single-example logits to a scalar. 

1433 circuit: The edges to keep, as ``(writer, reader)`` pairs or the 

1434 ``(writer, reader, score)`` triples ``top_edges`` returns. Every 

1435 other edge in the graph is ablated. Duplicate entries count once. 

1436 config: Ablation configuration. Defaults to replacing an ablated writer 

1437 with the corrupt run's own contribution. 

1438 mean_tokens: Required only for ``ablation="mean"``. Held-out ``int32``/``int64`` 

1439 calibration token ids, ``[n_calibration, seq]`` at the evaluated length and 

1440 on the clean/corrupt device; writer activations are averaged per position 

1441 and head. Rows equal to either endpoint are rejected; avoid semantic leakage too. 

1442 

1443 Returns: 

1444 A :class:`FaithfulnessResult`. The per-edge-class breakout costs one extra 

1445 forward per retained class. 

1446 

1447 Raises: 

1448 ValueError: if ``clean``/``corrupt`` are not 2D, hold a different number 

1449 of pairs, hold more than one pair, a pair tokenizes to different 

1450 lengths, the model or a submodule is in training mode, a circuit edge 

1451 is not in the graph, the clean and corrupt metrics are equal (so 

1452 the recovered fraction is undefined), mean calibration is missing, 

1453 empty, misaligned, on a different input device, has an unsupported 

1454 dtype, contains an evaluated endpoint, or is supplied in corrupt mode; 

1455 or per-head attention outputs are normalized or scaled before the 

1456 residual add (post-norm, scaled-residual or parallel-mixer blocks), or an 

1457 attention layer's K/V head count differs from ``n_heads`` (GQA/MQA). 

1458 """ 

1459 _validate_pair(clean, corrupt, operation="faithfulness", method="Faithfulness") 

1460 if clean.shape[0] != 1: 

1461 raise ValueError( 

1462 "faithfulness measures one clean/corrupt pair at a time, since the " 

1463 f"metric reads a single example; got {clean.shape[0]} pairs. Loop over " 

1464 "pairs and aggregate the recovered fractions yourself." 

1465 ) 

1466 

1467 if config.ablation == "mean": 

1468 if mean_tokens is None: 

1469 raise ValueError("mean ablation requires independent calibration via mean_tokens") 

1470 if mean_tokens.ndim != 2: 

1471 raise ValueError("mean_tokens must be a 2D [n_calibration, seq] token tensor") 

1472 if mean_tokens.shape[0] == 0: 

1473 raise ValueError("mean_tokens must contain at least one calibration example") 

1474 if mean_tokens.shape[1] != clean.shape[1]: 

1475 raise ValueError("mean_tokens must have the same sequence length as the evaluated pair") 

1476 if mean_tokens.dtype not in (torch.int32, torch.int64): 

1477 raise ValueError( 

1478 "mean_tokens must contain token ids with dtype torch.int32 or torch.int64" 

1479 ) 

1480 if mean_tokens.device != clean.device or mean_tokens.device != corrupt.device: 

1481 raise ValueError("mean_tokens must be on the same device as the clean/corrupt tokens") 

1482 if (mean_tokens == clean).all(dim=1).any() or (mean_tokens == corrupt).all(dim=1).any(): 

1483 raise ValueError("mean_tokens must not contain either evaluated clean/corrupt endpoint") 

1484 elif mean_tokens is not None: 

1485 raise ValueError("mean_tokens is only accepted with mean ablation") 

1486 

1487 require_eval_mode(model, operation="faithfulness()") 

1488 _check_attention_write_structure(model) 

1489 _check_kv_heads_match_query_heads(model) 

1490 

1491 kept = _normalize_circuit(circuit) 

1492 n_layers = int(model.cfg.n_layers) 

1493 hook_names = _edge_hook_names(n_layers) + _attn_write_hook_names(n_layers) 

1494 

1495 with torch.no_grad(), _edge_hook_flags(model): 

1496 corrupt_cache = cache_activation_and_gradient( 

1497 model, corrupt, metric_fn, names_filter=hook_names, compute_gradient=False 

1498 ) 

1499 _check_attention_write_is_head_sum(corrupt_cache, n_layers) 

1500 edges = enumerate_edges(model, corrupt_cache) 

1501 graph = set(edges) 

1502 unknown = [edge for edge in kept if edge not in graph] 

1503 if unknown: 

1504 raise ValueError( 

1505 f"circuit holds {len(unknown)} edge(s) that are not in the graph, " 

1506 f"starting with {unknown[0]}; a stale or mistyped circuit would " 

1507 "otherwise ablate nothing." 

1508 ) 

1509 

1510 if mean_tokens is not None: 

1511 replacements = _mean_writer_contributions(model, mean_tokens) 

1512 else: 

1513 replacements = capture_writer_outputs(model, corrupt) 

1514 

1515 with torch.no_grad(): 

1516 clean_metric = float(metric_fn(model(clean))) 

1517 corrupt_metric = float(corrupt_cache.metric) 

1518 gap = clean_metric - corrupt_metric 

1519 if gap == 0.0: 

1520 raise ValueError( 

1521 "the clean and corrupt runs produce the same metric, so the recovered " 

1522 "fraction is undefined; choose a pair the metric separates." 

1523 ) 

1524 ablated_metric = float(metric_fn(_ablate_edges(model, clean, edges, kept, replacements))) 

1525 

1526 recovered = (ablated_metric - corrupt_metric) / gap 

1527 class_counts: dict[EdgeClass, int] = {} 

1528 for edge in edges: 

1529 edge_class = _edge_class(edge) 

1530 class_counts[edge_class] = class_counts.get(edge_class, 0) + 1 

1531 

1532 kept_classes = {_edge_class(edge) for edge in kept} 

1533 class_recovered: dict[EdgeClass, float] = {} 

1534 for edge_class in class_counts: 

1535 if edge_class not in kept_classes: 

1536 class_recovered[edge_class] = recovered 

1537 continue 

1538 # Removing a class must not restore edges excluded by the candidate. 

1539 reduced = [edge for edge in kept if _edge_class(edge) != edge_class] 

1540 class_metric = float( 

1541 metric_fn(_ablate_edges(model, clean, edges, reduced, replacements)) 

1542 ) 

1543 class_recovered[edge_class] = (class_metric - corrupt_metric) / gap 

1544 

1545 return FaithfulnessResult( 

1546 recovered=recovered, 

1547 full_metric=clean_metric, 

1548 corrupt_metric=corrupt_metric, 

1549 circuit_size=len(kept), 

1550 total_edges=len(edges), 

1551 edge_class_recovered=class_recovered, 

1552 edge_class_counts=class_counts, 

1553 )