Before you move a control in the demo, say what you expect to change.
Foundation reference
Automated Circuit Discovery: Patching, Attribution & Decomposition at Scale
Automated circuit discovery searches a model's computational graph for a small subgraph that reproduces one chosen behavior. Because the search is a procedure rather than a hand-built case study, it can be rerun on new behaviors and larger models.
Study guide
Where to start
Activation patching measures a causal effect directly, while attribution patching estimates it linearly; when is the faster estimate good enough?
How to study this pagePredict · Check · Generalize · Continue
Compare what the demo shows with the key equation and with the papers cited below.
Identify the part of the idea that survives a change of notation or model, so you can recognize it in another paper or system.
Use the prerequisites and related concepts at the end of the page to fill the next gap.
Why It Matters for Modern Models
- Automated circuit discovery searches a model's computational graph for a small subgraph that reproduces one chosen behavior. Because the search is a procedure rather than a hand-built case study, it can be rerun on new behaviors and larger models.
- Speed has driven recent methods: ACDC patches one edge at a time, edge attribution patching estimates all edges from one backward pass, and contextual decomposition (CD-T) reports cutting runtime from hours to seconds (Conmy et al., 2023; Syed et al., 2024; Hsu et al., 2025).
- Finding the circuit behind a behavior is a prerequisite for editing or steering that behavior on purpose.
- The same scoring can be applied to sparse-autoencoder features instead of heads and neurons, which gives circuits made of interpretable units (Marks et al., 2025).
- Results depend on the chosen metric and on how inputs are corrupted, so circuit discovery is also a lesson in how measurement choices shape interpretability findings (Zhang & Nanda, 2024).
Questions to Think About
- Why can the choice of metric decide the result, for example when a metric such as the KL divergence to the clean output has zero gradient at the clean run, so attribution patching sees no signal?
- Why are circuits not unique, so that pruning finds a mechanism that reproduces the behavior rather than the mechanism?
- Activation patching is exact only for the chosen graph, inputs, corrupted inputs and metric; what is gained and what is lost when it is replaced by a fast approximation?
- Why is circuit discovery better understood as testing the hypothesis that a subgraph explains a behavior than as reading off ground truth?
Interactive Demos
Controlled patching · one known graph
Does this observation justify deleting a connection?
Reading a value from a record shows that it carries information. It does not show that a later computation uses it. Here the graph is known, so each claim can be checked by replacing a named input and recomputing everything downstream. The same record, donor and metric serve the drawing, the numbers, the pruning pass and the code.
The example. Source activations a, b and c, each in [0, 1]. One receiver O has two input slots, ã and b̃, fed by edge A (a → O) and edge B (b → O); c has no edge to O. O computes z = ã + b̃ − ãb̃, the margin m = 2z − 1 and the probability p = σ(m) = 1/(1 + e−m) of the positive class. The clean record is (a, b, c) = (1, 1, 1). The donor cache (a′, b′, c′), chosen below, is recorded once and never recomputed. This is a hand-built graph for checking a method: it is not a trained model, and nothing here discovers its structure.
What the records show
Two rows are supplied. Each records a label y and the three source values.
| row | y (recorded label) | a | b | c |
|---|---|---|---|---|
| 1 | 0 | 0 | 0 | 0 |
| 2 | 1 | 1 | 1 | 1 |
Reading any single coordinate recovers y on both rows: a, b and c each equal y. Nothing was fitted, and nothing is claimed about rows that were not supplied. On these rows O computes z = y and m = 2y − 1. Keep the four symbols apart: y is recorded with the row; z, m and p are computed by O from its two slots.
Why all three agree
These rows were produced by copying one label into a, b and c. A shared input makes values agree with each other and with y without any of them feeding the others or O. The graph, which is given here, says what O reads: slots ã and b̃, never c. So c is decodable and unused by O. The records cannot separate these possibilities, because in every recorded row the three values move together.
What would separate them is a comparison the records never contain: set one value while holding the others fixed, then recompute O. That is an intervention. It constructs a row nobody observed.
Reading a representation, in more depth: Probing.
Replace one named input and recompute
A replacement names three things: the recipient (which input slot of which receiver), the donor value it receives, and everything downstream that is recomputed. The source keeps its own value; only what the recipient reads changes.
- sources
- a = 1, b = 1, c = 1 (unchanged)
- donor cache
- a′, b′, c′ = (0, 0, 0)
- slot ã
- 0, from donor a′
- slot b̃
- 1, from source b
- z, m, p
- 1, 1, 0.7311
- F against p₀
- 0 nats
Slot ã receives 0 while source a keeps its value 1. z stays 1: with b̃ = 1, z = 1 − (1 − ã)(1 − b̃) = 1 whatever ã is. This zero effect says the other slot is sufficient in this state. It does not say edge A is unnecessary in every state.
| replacement | ã | b̃ | c at its source | z | m | p | F, nats |
|---|---|---|---|---|---|---|---|
| None | 1 | 1 | 1 | 1 | 1 | 0.7311 | 0 |
| Slot ã (edge A) (selected) | 0 | 1 | 1 | 1 | 1 | 0.7311 | 0 |
| Slot b̃ (edge B) | 1 | 0 | 1 | 1 | 1 | 0.7311 | 0 |
| Both slots | 0 | 0 | 1 | 0 | −1 | 0.2689 | 0.4621 |
| Node c | 1 | 1 | 0 | 1 | 1 | 0.7311 | 0 |
Conditional replacement
ã = eAa + (1 − eA)a′. The retained-edge bit eA picks the clean source value (eA = 1) or the cached donor value (eA = 0). The donor was recorded once, from another input; it is never recomputed from the patched graph.
Here a and b each feed only O, so replacing node a and replacing edge A give the same output. With a second consumer of a the two interventions could differ; this graph cannot show that.
Prune against the current graph
A pruning pass tests one edge at a time against the graph that is currently kept. It compares the output distribution with the original full graph’s, fixed before the pass begins.
The metric
F(H) = KL(Bern(p₀) ‖ Bern(pH)) = p₀ log(p₀/pH) + (1 − p₀) log((1 − p₀)/(1 − pH)), in nats. It is 0 when H reproduces p₀ exactly and grows as pH moves away. The reference p₀ = σ(1) = 0.7311 is the full graph’s output on the clean record, and it is never replaced by the current graph’s output. KL divergence, in more depth: KL divergence.
For each edge in the stated order: Htrial = H without the edge; Δ = F(Htrial) − F(H); remove the edge if and only if Δ < τ, with τ = 0.1 nats. A removal persists; after a rejection H is what it was. Δ is signed, so a negative Δ passes, and Δ = τ does not.
Pass computed from the full graph {A, B}: donor (0, 0, 0), order A, then B, τ = 0.1 nats.
Step 1: test edge A (a → O)
- current H
- {A, B}; slots (1, 1); m = 1; F(H) = 0
- trial H without A
- {B}; slots (0, 1); m = 1; F = 0
- Δ = F(trial) − F(H)
- 0 − 0 = 0
- Δ < τ = 0.1?
- yes
- decision
- remove A; H becomes {B}
Step 2: test edge B (b → O) (reading)
- current H
- {B}; slots (0, 1); m = 1; F(H) = 0
- trial H without B
- { }; slots (0, 0); m = −1; F = 0.4621
- Δ = F(trial) − F(H)
- 0.4621 − 0 = 0.4621
- Δ < τ = 0.1?
- no
- decision
- keep B; the trial is discarded and H stays {B}
Final graph
- retained H
- {B}; slots (0, 1)
- m, p
- 1, 0.7311
- F(H)
- 0 nats
Step 2 tests edge B against the current graph {B}. The trial graph { } feeds b̃ the donor value 0, and everything downstream is recomputed: m = −1, p = 0.2689, F = 0.4621. Δ = 0.4621 − 0 = 0.4621, and 0.4621 < 0.1 is false. Edge B is kept. The trial is discarded, and the current graph {B} is what the pass holds afterwards.
The first trial replaces ã and costs nothing (Δ = 0), so edge A is removed and stays removed. The second trial is judged against that current graph, in which edge B now carries the output: removing it gives F = 0.4621 and Δ = 0.4621, not below τ, so B is kept. Reverse the order and the other edge is kept. Both results reproduce the clean output exactly, so the retained graph belongs to this pass, its order and its donor; it is not a unique mechanism. Scoring each edge once against the full graph would give both a cost of 0; the second trial differs because it runs in the graph the first decision left.
Hold the retained graph and change the input
The pass saw one clean record. Keep its final graph and the same donor cache, and evaluate the four binary inputs (a, b); O does not read c.
| (a, b) | recorded? | full graph m | retained graph m | same? |
|---|---|---|---|---|
| (0, 0) | row with y = 0 | −1 | −1 | same |
| (1, 1) | row with y = 1 | 1 | 1 | same |
| (1, 0) | no row, no label | 1 | −1 | differs |
| (0, 1) | no row, no label | 1 | 1 | same |
On both recorded rows the retained graph {B} gives the full graph's margin. At (1, 0), which no record contains, it gives −1 where the full graph gives 1. That is a difference in fidelity to the full graph, found only by evaluating an input the pass never saw. No label is supplied there, so it is not a task error.
In the source
Conmy et al., arXiv 2304.14997 v2, §2.3 and §3, Algorithm 1 (printed p. 6): start from the full graph; visit nodes from the output back towards the inputs; for each incoming edge, compare the KL divergence of the graph without it with that of the current graph, and remove it when the increase is strictly below τ. Absent edges read activations cached from a corrupted input. This page specializes the rule to one receiver with two parents in a stated order and one clean/donor pair; it omits averaging over examples, larger graphs, transformer hook semantics, threshold sweeps and the paper’s recovery evaluations. The paper’s appendices vary the metric and the corrupted inputs.
That version’s Appendix B.1 Eq. 2 (printed p. 16) prints the comparison the other way round, F(H) − F(Hnew) < τ, while Algorithm 1 and Appendix B.1 Eq. 1 use F(Hnew) − F(H) < τ. This page follows Algorithm 1. The difference is not cosmetic: under the printed Eq. 2 this pass’s second trial compares −0.4621 with τ, so the pass would end with H = { } and F = 0.4621 nats.
A discrepancy in one arXiv version, checked against its printed pages; nothing is asserted about other versions. The top-k expression in the math section below ranks scores from one pass; it is not this procedure.
Exact changes and local estimates
Attribution patching replaces many exact replacements with one gradient: the estimated change is (replacement − base) × (derivative at the base). Every exact change is available here, so each estimate can be checked against it.
A derivative is local
∂m/∂b̃ is the rate at which m changes for a small change of b̃ at one base point. A replacement is a finite change, often all the way from 1 to 0, so a first-order estimate can miss curvature and interactions. Derivatives, in more depth: Derivatives.
Δm = 2(1 − b̃)δã + 2(1 − ã)δb̃ − 2δãδb̃, δ = replacement − base, exactly.
The first two terms are the first-order estimate at the base. Because m is affine in each slot separately, a single-slot estimate of m is exact at any base; only the interaction −2δãδb̃ is missed, and only when both slots change.
| base (ã, b̃) | replace | δ(ã, b̃) | ∂m/∂(ã, b̃) at base | first order | exact Δm |
|---|---|---|---|---|---|
| intact (1, 1) | ã only | (−1, 0) | (0, 0) | 0 | 0 |
| intact (1, 1) | b̃ only | (0, −1) | (0, 0) | 0 | 0 |
| intact (1, 1) | both slots | (−1, −1) | (0, 0) | 0 | −2 |
| step-2 graph (0, 1) | b̃ (the second trial) | (0, −1) | (0, 2) | −2 | −2 |
| step-2 graph (0, 1) | b̃, intact gradient reused | (0, −1) | (0, 0), from (1, 1) | 0 | −2 |
At the intact graph both derivatives are 0, and each single replacement really does change m by 0: those estimates are exact, not wrong. The joint replacement changes m by −2, all of it interaction, which a sum of single-slot estimates cannot see. After the first removal the base is (0, 1), where ∂m/∂b̃ = 2 and the estimate −2 is exact. A gradient computed once at the clean graph and reused there predicts 0: it answers a question about another base.
exact first order at the base
| r | 0 | 0.25 | 0.5 | 0.75 | 1 |
|---|---|---|---|---|---|
| exact m | 1 | 0.875 | 0.5 | −0.125 | −1 |
| first order | 1 | 1 | 1 | 1 | 1 |
exact first order at the base
| r | 0 | 0.25 | 0.5 | 0.75 | 1 |
|---|---|---|---|---|---|
| exact p | 0.7311 | 0.6225 | 0.5 | 0.3775 | 0.2689 |
| first order | 0.7311 | 0.6328 | 0.5344 | 0.4361 | 0.3378 |
r is how far each replaced slot has moved from its base value towards the donor value (r = 1 is the replacement itself); it is not time and not a sample.
Read the second trial through p instead of m. At base (0, 1), replacing b̃ by 0 changes p by exactly −0.4621; the first-order estimate ∂p/∂b̃ · δ = p(1 − p) · 2 · (−1) gives −0.3932. Along this path m changes linearly and p does not, because σ curves: a genuine single-edge first-order error, in probability units. Probability, margin and F are different quantities. Here |Δp| and F share the digits 0.4621: with this symmetric donor both equal 2σ(1) − 1. That is a coincidence of this case, and the half donor separates them. None of these numbers is compared with τ. The pass compares only the signed change F(Htrial) − F(H), in nats; a trial whose own F exceeds τ still passes when that change is below τ (the second example under “The pass, beyond this record” in the mathematics below).
A second, different zero
The slope of F is dF/dm = σ(m) − p₀. It is 0 at the intact graph and 0 at the step-2 graph, where the output still matches the reference. So ∂F/∂b̃ = (dF/dm)(∂m/∂b̃) = 0 × 2 = 0 there, while the exact trial moves F to 0.4621. This zero comes from F being smallest at the reference, whatever the slots do; it is not the intact graph’s flat margin.
In the source
Syed, Rager and Conmy, BlackboxNLP 2024, §3.1–3.3 (printed pp. 408–409): Eq. 2 estimates an edge’s signed effect as (ecorr − eclean)ᵀ∇eL at the clean run: replacement minus base, as in the table above. The paper averages absolute scores over input pairs and keeps the top-k edges; one scalar pair reproduces neither step. It does not linearize the KL divergence from the unmodified model, because that gradient is zero at the clean distribution (§3.3), the zero shown above. Appendix F shares one gradient among edges into an additive destination; the slots of O are nonlinear and each has its own derivative.
The paper-linked code, prune_utils.py at commit 3702573, line 283, multiplies the clean-run gradient by clean minus corrupted, sums the resulting tensor, and then optionally takes an absolute value (lines 285–286). One pair’s magnitude is unaffected by the reversed direction, but its sign is: at base (0, 1) with the zero donor, (0 − 1) × 2 = −2 is the exact signed change, and the opposite subtraction gives +2. In general, adding signed contributions before an absolute value is not the same as averaging magnitudes: contributions +2 and −2 have mean absolute value 2 and absolute mean 0. Which axes that sum runs over, and so whether it matches the paper’s averaging across pairs, is not established here.
This compares conventions and establishes no error in that code: its metric, sign and reduction have to be read together. This local scalar estimate is not the full EAP ranking procedure, and nothing here compares the performance of the two methods.
The mathematics: slots, the exact expansion, the divergence and the pass
Symbols. a, b, c ∈ [0, 1] are source activations of the clean record and a′, b′, c′ those of the donor cache. eA, eB ∈ {0, 1} mark whether edges A and B are retained in a graph H. ã = eAa + (1 − eA)a′ and b̃ = eBb + (1 − eB)b′ are the slots O reads. z = ã + b̃ − ãb̃ = 1 − (1 − ã)(1 − b̃), m = 2z − 1 and p = σ(m). y is a recorded label and never enters O. m₀ and p₀ are the full graph’s margin and probability on the clean record. τ > 0 is in nats.
The exact expansion. With increments δã, δb̃ at a base (ã, b̃), z changes by δã + δb̃ − (ãδb̃ + b̃δã + δãδb̃), so Δm = 2(1 − b̃)δã + 2(1 − ã)δb̃ − 2δãδb̃ with no remainder. The first two terms are the gradient at the base times the increments.
The divergence. With softplus(t) = log(1 + et), log p = −softplus(−m), log(1 − p) = −softplus(m) and softplus(−m) = softplus(m) − m. Substituting into the Bernoulli KL gives F(H) = softplus(mH) − softplus(m₀) − p₀(mH − m₀), whose derivative in mH is σ(mH) − p₀. Swapping the arguments gives another function; the half donor shows the difference.
The pass, beyond this record. Two other clean records, both with order A then B, checked by the helper and asserted by the code: with clean (0.5, 0.5, ·) and donor (0, 0.75, ·), Δ₁ = 0.0303 and Δ₂ = −0.0303, so a signed decrease passes and both edges go. With clean (1, 0.5, ·) and donor (0.5, 0, ·), Δ₁ = 0.0263 and Δ₂ = 0.0846 are both below τ, and the final F = 0.1109 exceeds τ: τ limits each step, not the result. (In the order B then A the second record keeps A, because its second increment is 0.1109.) In a two-edge pass that starts from the full graph, a negative second increment is always greater than −τ, so comparing |Δ| instead of Δ would make the same decisions here; in longer passes it need not.
Arithmetic and domain. Records must give exactly a, b and c as finite numbers in [0, 1]; edges are A and B only, without repeats; an order names both; τ is finite and positive. Anything else is refused, not repaired. On this domain z ∈ [0, 1] and m ∈ [−1, 1], also after rounding, so no probability needs clamping. The divergence functions in the code accept only margins in [−1, 1]: this is the divergence of this graph, not a general-purpose one, and far outside that range its softplus form would subtract huge, nearly equal terms. F is exactly 0 when mH = m₀ exactly; within a few rounding units of m₀ it is rounding-dominated (about 1e−15) and is used as computed, with no tolerance in the comparison.
The code: the same computation in Python
It runs as written with Python 3 and NumPy, prints the six passes and the estimates above, and asserts the strict and signed comparison, the reversed-inequality step, a signed decrease, the unused c and the direction of the divergence. It refuses the inputs the page’s TypeScript helper refuses, except JavaScript-only shapes (inherited or accessor properties, sparse arrays), which Python does not have. The pass under the printed Appendix B.1 Eq. 2 is computed only by the TypeScript helper.
import math
import numpy as np
# The graph, given here. Sources a, b, c. One receiver O reads two input slots, a~ and b~, through
# edge A (a -> O) and edge B (b -> O). There is no edge from c to O.
EDGES = ("A", "B")
CLEAN = {"a": 1.0, "b": 1.0, "c": 1.0}
DONORS = {"zero": {"a": 0.0, "b": 0.0, "c": 0.0}, # the recorded y = 0 row
"half": {"a": 0.5, "b": 0.5, "c": 0.5}, # chosen, not an observed row
"same": {"a": 1.0, "b": 1.0, "c": 1.0}} # equal to the clean record: a no-op control
TAU = 0.1 # nats, allowed per trial deletion
def number(v):
return not isinstance(v, bool) and isinstance(v, (int, float)) and math.isfinite(v)
def check_record(x, name):
"""Exactly a, b and c, each a finite number in [0, 1]. Nothing is coerced."""
if type(x) is not dict or set(x) != {"a", "b", "c"}:
raise ValueError(f"{name} must give exactly a, b and c")
if not all(number(x[k]) and 0 <= x[k] <= 1 for k in "abc"):
raise ValueError(f"{name}: a, b and c must be finite numbers in [0, 1]")
def check_slots(s, name):
"""A slot pair (a~, b~) of finite numbers in [0, 1]."""
if not isinstance(s, (list, tuple)) or len(s) != 2 or not all(number(v) and 0 <= v <= 1 for v in s):
raise ValueError(f"{name} must be two finite numbers in [0, 1]")
def check_edges(edges, name):
"""Distinct edges of O. c has no edge, so "C" is refused, not ignored."""
if not isinstance(edges, (list, tuple)) or len(edges) > 2 or any(e not in EDGES for e in edges) \
or len(set(edges)) != len(edges):
raise ValueError(f"{name} must list distinct edges from A and B")
def check_pair(g, name):
"""Two finite numbers of any sign: a gradient or an increment."""
if not isinstance(g, (list, tuple)) or len(g) != 2 or not all(number(v) for v in g):
raise ValueError(f"{name} must be two finite numbers")
def slots(x, donor, kept):
"""(a~, b~) = e * clean + (1 - e) * donor, with retained-edge bits e. A removed edge's slot
receives the cached donor value; the sources themselves keep their values."""
check_record(x, "clean")
check_record(donor, "donor")
check_edges(kept, "retained edges")
e = np.array([1.0 if "A" in kept else 0.0, 1.0 if "B" in kept else 0.0])
s = e * np.array([float(x["a"]), float(x["b"])]) + (1.0 - e) * np.array([float(donor["a"]), float(donor["b"])])
return float(s[0]), float(s[1])
def sigmoid(m):
if m >= 0:
return 1.0 / (1.0 + math.exp(-m))
e = math.exp(m)
return e / (1.0 + e)
def output(at, bt):
"""What O computes from its slots: z, the margin m and the probability p. c is not an input."""
z = at + bt - at * bt
m = 2.0 * z - 1.0
return z, m, sigmoid(m)
def softplus(m):
"""log(1 + e^m) without overflow."""
return m + math.log1p(math.exp(-m)) if m > 0 else math.log1p(math.exp(m))
def forward_kl(m0, m):
"""KL(Bern(sigmoid(m0)) || Bern(sigmoid(m))) in nats. m0 is the full graph's margin on the clean
record: a fixed reference, never the current graph's output. Margins lie in [-1, 1], the range
this graph produces; far outside it this form is not used and not trusted."""
if not (number(m0) and number(m) and -1 <= m0 <= 1 and -1 <= m <= 1):
raise ValueError("margins must be finite numbers in [-1, 1], the margins this graph produces")
return softplus(m) - softplus(m0) - sigmoid(m0) * (m - m0)
def removes(delta, tau):
"""Strict and signed: a negative increment passes whatever its size; equality does not."""
if not number(delta) or not number(tau) or not tau > 0:
raise ValueError("delta must be finite and tau a finite positive number of nats")
return delta < tau
def prune(clean, donor, order, tau):
"""One pass: test each edge against the graph currently kept, and remove it iff
F(trial) - F(current) < tau, strictly and with sign."""
check_record(clean, "clean")
check_record(donor, "donor")
if not isinstance(order, (list, tuple)) or len(order) != 2:
raise ValueError("a pass order names A and B once each")
check_edges(order, "pass order")
if not number(tau) or not tau > 0:
raise ValueError("tau must be a finite positive number of nats")
m0 = output(*slots(clean, donor, EDGES))[1] # full graph: the reference for every step
kept, steps = list(EDGES), []
for edge in order:
trial = [e for e in kept if e != edge]
f_now = forward_kl(m0, output(*slots(clean, donor, kept))[1]) # from H itself, not carried over
f_try = forward_kl(m0, output(*slots(clean, donor, trial))[1])
delta = f_try - f_now # signed
removed = removes(delta, tau)
steps.append((edge, list(kept), trial, f_now, f_try, delta, removed))
if removed:
kept = trial # an accepted removal persists
# otherwise nothing is assigned: H stays the graph it was before the trial
return m0, kept, steps
def margin_gradient(base):
"""dm/da~ = 2(1 - b~) and dm/db~ = 2(1 - a~) at a base (a~, b~)."""
check_slots(base, "base")
return 2.0 * (1.0 - base[1]), 2.0 * (1.0 - base[0])
def margin_change(base, new, grad=None):
"""Exact m(new) - m(base) against the first-order estimate. Increments are replacement minus base;
the gradient is the base's own unless another is supplied (that is how a stale one is reused)."""
check_slots(base, "base")
check_slots(new, "replacement")
da, db = new[0] - base[0], new[1] - base[1]
g = margin_gradient(base) if grad is None else grad
check_pair(g, "gradient")
return output(*new)[1] - output(*base)[1], g[0] * da + g[1] * db, -2.0 * da * db
def probability_change(base, new):
"""The same replacement read through p = sigmoid(m): exact change and first-order estimate."""
check_slots(base, "base")
check_slots(new, "replacement")
p = output(*base)[2]
g = margin_gradient(base) # dp/dslot = p(1 - p) * dm/dslot
da, db = new[0] - base[0], new[1] - base[1]
return output(*new)[2] - p, p * (1.0 - p) * g[0] * da + p * (1.0 - p) * g[1] * db
def show(v):
return f"{v + 0.0:.6f}" # + 0.0 prints -0.0 as 0.000000
for name, donor in DONORS.items():
for order in (("A", "B"), ("B", "A")):
m0, kept, steps = prune(CLEAN, donor, order, TAU)
for edge, now, trial, f_now, f_try, delta, removed in steps:
print(f"{name} {''.join(order)} | test {edge} | H {now} -> {trial} | F {show(f_now)} -> "
f"{show(f_try)} | delta {show(delta)} | {'remove' if removed else 'keep'}")
print(f"{name} {''.join(order)} | kept {kept}")
intact, after_a, empty = (1.0, 1.0), (0.0, 1.0), (0.0, 0.0) # slot pairs under the zero donor
exact, linear, interaction = margin_change(intact, empty)
print("m, both slots at (1, 1) | exact", show(exact), "| first order", show(linear), "| interaction", show(interaction))
exact, linear, _ = margin_change(after_a, empty)
stale = margin_change(after_a, empty, margin_gradient(intact))[1]
print("m, b~ -> 0 at (0, 1) | exact", show(exact), "| first order", show(linear), "| (1, 1) gradient reused", show(stale))
exact, linear = probability_change(after_a, empty)
print("p, b~ -> 0 at (0, 1) | exact", show(exact), "| first order", show(linear))
# Signed and strict: a negative increment passes, equality does not. At tau equal to the second
# increment the edge is still kept.
assert removes(-1.0, TAU) and not removes(TAU, TAU)
second = prune(CLEAN, DONORS["zero"], ("A", "B"), TAU)[2][1]
assert prune(CLEAN, DONORS["zero"], ("A", "B"), second[5])[1] == ["B"]
# Appendix B.1 Eq. 2 as printed compares F(H) - F(trial) = -delta, which would remove B as well.
assert -second[5] < TAU and not second[6]
# Two clean records this page does not use, order A then B. A negative increment passes:
_, kept, steps = prune({"a": 0.5, "b": 0.5, "c": 0.0}, {"a": 0.0, "b": 0.75, "c": 0.0}, ("A", "B"), TAU)
assert steps[1][5] < 0 and kept == []
# and tau limits each step, not the result: both increments pass and the final F exceeds tau.
_, kept, steps = prune({"a": 1.0, "b": 0.5, "c": 0.0}, {"a": 0.5, "b": 0.0, "c": 0.0}, ("A", "B"), TAU)
assert kept == [] and all(step[5] < TAU for step in steps) and steps[1][4] > TAU
# Replacing node c changes c for its consumers; O is not one, so nothing O computes changes.
assert slots(dict(CLEAN, c=0.0), DONORS["zero"], EDGES) == slots(CLEAN, DONORS["zero"], EDGES)
# The direction of the divergence: forward, from the fixed reference. The reverse gives 0.027955.
assert abs(forward_kl(1.0, 0.5) - 0.0263445859768863) < 1e-12
Practice (optional)
Five short checks on the same graph. A hint comes before the answer. Beside each item this page notes your first check and any help opened; a later correct check never changes the first. Nothing is saved or sent, and reloading clears it. A shown answer is assistance, not an unassisted success. Opening practice or help does not change the figures above.
Zero donor, order B then A. What is the signed increment Δ at the second trial, to four decimal places?
At base (ã, b̃) = (0, 1), what change in m does the first-order estimate at that base predict for replacing b̃ by 0?
What this does not show. The graph is given, so nothing here discovers a mechanism: the pass checks a method on a known example. One clean record and one donor are not the averaged examples of the published methods, and no trained model, fitted probe, benchmark or learner outcome is measured. The source notes report what the papers and code state; this example reproduces none of their experiments.
Two adjacent lessons follow, unchanged: induction heads and sparse autoencoders. They use their own examples, and neither is an output of this investigation.
Core Math
If you prefer intuition first, try the demos above, then come back for the math.
Automated circuit discovery treats interpretability as a search problem: score every edge (or node) of the model's computational graph by how much it matters for a behavior, then keep a small subgraph, the circuit, that reproduces the behavior.
Activation patching (a causal intervention):
Run the model on a clean input and on a corrupted input that changes the behavior. For an edge , replace its clean value with its value from the corrupted run and measure the change in a metric , such as the loss or a logit difference:
Each edge needs its own forward pass, which makes this slow on large graphs (Conmy et al., 2023).
Attribution patching (a first-order approximation):
Two forward passes and one backward pass give the estimate for every edge at once (Syed et al., 2024). The linear approximation can be inaccurate when patching changes an activation a lot. The gradient is taken at the clean run, so each estimate describes replacing one edge in the unmodified model, not an edge tested after others have already been removed.
Scoring and ranking:
Scoring every edge once against the unmodified model and keeping the highest is a ranking rule, close to how attribution patching selects edges from absolute scores averaged over input pairs (Syed et al., 2024). ACDC works differently: it tests one edge at a time against the current, already-pruned graph and removes it only when the increase in KL divergence stays below a threshold (Conmy et al., 2023). Neither procedure guarantees a minimal circuit, or that the behavior is preserved on inputs it did not evaluate; the candidate circuit is then checked by ablating the rest of the model.