Which variable does the model return?

An attribution-graph study of gemma-2-2b completing a Python function

In one paragraph

Give gemma-2-2b a Python function that accumulates into a local variable, and cut the prompt off right after return. The model predicts  result, the accumulator it bound four lines earlier, with 95.3% probability. I used circuit-tracer to build an attribution graph over that prediction. The answer is not a frequency prior over common variable names. A specific mid-layer feature fires on the result tokens, and a band of late-layer features on return adds to the same logit.

One graph cannot say whether that feature tracks the variable being bound or simply the name result. In this prompt they coincide. Control prompts that move the binding around separate the two, and the answer is the name. Each variable name has its own pathway, and the pathways are orthogonal. The nodes sitting on result drive the  result logit and essentially nothing else. The nodes sitting on a second name, ans, drive  ans and essentially nothing else. Binding does not switch a pathway on or off. It sets how much drive that name accumulates, and the model takes the winner at the logit layer.

Three things then hold this up. Across five graphs no pathway pushes the rival logit by more than 2.0 while pushing its own by 17 to 39. The effect is not about the word result: 18 accumulator names behave the same way over four prompt shapes, including runnable Python. And it is causal: scale the winning name's feature down and the probabilities cross over, so the model returns the other variable.

Before the graph, there is a cheaper question: where the model looks from return. The answer is the same in every prompt. The heads that carry the names attend to the assignment line and the loop line, and what changes between prompts is the name that happens to sit there.

The task

The prompt is a complete, unambiguous function whose body binds one local variable. Everything the model needs to answer correctly is in scope. Nothing in the surrounding text hints at the name. This is the first of four prompts, P1. The other three are controls (P2–P4) that move the binding around, and the interpretation ultimately rests on them.

P1 · return-variable. result is bound twice.

def calculate_total(prices):
    result = 0
    for price in prices:
        result = result + price
    return

That is 31 tokens including <bos>. The two writes to result land at positions 10 and 23. The final return is position 30, and that is where the next-token prediction is made.

What the model predicts

RankTokenProbability
1 result95.31%
2(1.06%
3 round0.59%
4 (0.52%
5 int0.28%
6 price0.26%

Note the sixth entry.  price, the loop variable, is the plausible wrong answer. A model that merely knew "a variable name goes here" could easily produce it. It gets 0.26%. Whatever the model is doing, it distinguishes the accumulator from the iteration variable.

Model
google/gemma-2-2b, bfloat16
Transcoders
mwhanna/gemma-scope-transcoders
Hardware
single RTX 3090 (24 GB), ~30 s per attribution run
Attribution
8,192 of 25,597 active feature nodes
Pruning
node threshold 0.8, edge threshold 0.98 (0.7 / 0.9 for the published graph)
Date
2026-08-19 (main prompt), 2026-08-22 (controls, name sweep, interventions)

Where the return position looks

The attribution graph below says what is moved toward each logit. It does not say where the model looks to fetch it. That is the QK side of each attention head, and it can be read straight off the attention pattern at the return position: one forward pass per prompt, no transcoders. The figure takes the ten heads that put the most attention mass on the two candidate names in P4, drops the BOS column (an attention sink that soaks up about 0.4 of the raw mass on average), renormalises the rest, and shows the same ten rows for all four prompts on one colour scale. Blue boxes mark the result tokens, red boxes the ans tokens.

Four heatmaps, one per prompt P1 to P4, each with the same ten attention heads as rows and the prompt's tokens as columns, showing the attention from the return position. In every prompt the dark cells sit on the assignment line and inside the loop; result columns are boxed in blue and ans columns in red.
Attention from return, BOS removed and renormalised. Rows are the ten heads with most mass on result / ans in P4, fixed across panels. P1 has no ans, so it carries blue boxes only. Same figure as the poster's WHERE band; generated by attention_at_return.py and replotted from the committed .npy matrices.

Three things to read off it. First, on P1 there is only one name on the page and the heads put their mass on it: L18H6 places 0.87 of its renormalised attention on the result tokens. Second, counted by name, P2 and P3 look different. L18H6 gives result 0.71 of its mass in P2 and 0.13 in P3, and ans the reverse. Third, counted by position, the same head looks at the same places in both: the assignment on line 2 and the accumulation inside the loop. Over the ten heads, the cosine between a head's P2 row and its P3 row has median 0.86; over all 208 heads it is 0.99. The heads attend to the structure of the function, and the name that happens to sit at those positions is what changes.

Two of these heads, L18H6 and L21H5, are among the model's strongest induction heads: 0.79 and 0.67 on repeated random token sequences, with a previous-token score under 0.04. That is the 2022 mechanism, [A][B]…[A] → [B], applied to a variable name that appeared earlier in the function.

Attention says where, not what. High weight on a token does not by itself mean that token's content is what moved the logit; Jain and Wallace (2019) make the point at length. The heatmap never stands alone here. The graph says what is moved, and the intervention says whether it matters.

Numbers: attention_return_summary.txt (masses, cosines, the branch decision) and head_scores.csv (all 208 heads) in the experiment outputs.

The circuit

Attribution circuit for the ` result` prediction Token embeddings for the two occurrences of result feed a layer-18 feature that fires on the result tokens, which together with a band of layer 21 to 25 features on the return token drives the ` result` logit; a layer-24 feature on return suppresses it.  result logit · 95.3% 24_15424_30 suppressor · −5.09 18_4769 layer 18 · fires on the `result` tokens fires at pos 10, 14, 23 · act 103 / 55 / 44 layers 21–25 @ pos 30 24_12518, 25_4717, 21_5255, 23_13877, 22_4943 … · +0.4 to +0.9 each result pos 10 · result = 0 result pos 23 · accumulate return pos 30 · prediction site token embeddings solid = promotes  ·  dashed red = suppresses
The attributed circuit, simplified. Edge thickness follows the direct effect on the  result logit. That is the only logit target in this graph, since the top prediction cleared the 0.95 coverage threshold on its own. The full graph (1,460 nodes and 56,975 edges) is explorable interactively.

Largest direct effects on the  result logit

NodeDirect effectPositionWhat it is
18_4769_10 +5.34 10 Layer-18 feature 4769 on the first result, where it is bound (activation 103)
24_15424_30 −5.09 30 Layer-24 feature on return, the main suppressor
18_4769_23 +2.73 23 The same feature 4769, on the accumulation line (activation 44)
E_10 +2.56 10 Raw token embedding of the first result
E_23 +2.20 23 Raw token embedding of the second result
24_12518_30 +0.89 30 Late-layer feature on return
18_4769_14 +0.78 14 Feature 4769 again, on the newline closing result = 0
21_5255_30 +0.61 30 Late-layer feature on return
25_4717_30 +0.51 30 Late-layer feature on return (activation 252)

Three things stand out.

One feature carries most of the positive effect, and it fires only on result. Feature 18_4769 is the single largest positive contributor. It appears three times in the top nine: at both writes to result and at the newline that closes the first one. It never fires on price or prices. Two readings fit this one graph equally well: a feature for "the local variable being assigned here", or a feature for the name result. The controls decide between them. 18_4769 is tied to the name, and it stays silent through a perfectly good binding of a different name.

The raw embeddings contribute directly. E_10 and E_23, the plain token embeddings of the two result occurrences, contribute +2.56 and +2.20 without passing through any feature. That is a copy path straight from the input. In the controls this path carries a large share of each name's total, and it is name-specific in the same way the features are.

The largest single effect is negative. 24_15424_30 pushes the  result logit down by 5.09, almost exactly cancelling the top promoter. The prediction survives because the promoting evidence is distributed across many nodes, not because any one of them overwhelms the suppressor. The controls settle what this feature is: result's own dedicated suppressor. It fires against  result just as hard when  result is losing. When the accumulator is a different name, a different layer-24 feature takes its place.

Most influential nodes overall

Direct effect measures one row of the adjacency matrix. Ranking instead by total influence (direct and indirect, the score the pruner uses) puts the structural tokens first:

RankNodeTokenKind
1E_1defembedding
2E_0<bos>embedding
3E_10resultembedding
4E_2 calculateembedding
518_4769_10resultfeature
6E_6pricesembedding
7E_4totalembedding
824_15424_30returnfeature

def and <bos> leading this ranking is expected. They gate the whole "this is a Python function body" computation, so almost everything routes through them. The informative entry is 18_4769_10 at rank 5. The identifier feature is influential on both measures, which is what separates it from nodes that merely sit on a busy path.

The controls

One prompt is not enough to interpret. In the function above, the name result and the variable that gets bound are the same thing. A feature that fires on result could be tracking either. The controls below pull the two apart, and they come down on the side of the name.

Each control keeps the function, the loop and the cut point identical, and changes only which name is bound where. Two of them are deliberately invalid Python because a name is read before it is ever assigned. Making binding and mere occurrence disagree is the only way to tell which one the model follows. The third is ordinary runnable code. It is the one to check first if the invalid ones bother you.

P2 · split-binding. ans is bound; result is accumulated into but never initialised. Invalid Python.

def calculate_total(prices):
    ans = 0
    for price in prices:
        result = result + price
    return

P3 · result-never-bound. ans is bound twice; result only ever appears on a right-hand side. Invalid Python.

def calculate_total(prices):
    ans = 0
    for price in prices:
        ans = result + price
    return

P4 · two-valid-locals. Both names are bound, and only result is accumulated into. This one runs.

def calculate_total(prices):
    ans = 0
    result = 0
    for price in prices:
        result = result + price
    return

How a prompt is scored

The same conventions as the two companion pages, with one difference. There are no pairs and no verdicts here. The question is which name the model picks and by how much, rather than whether it is right. Four layers, each with its own measurement:

  • Behaviour (P1–P4). One forward pass per prompt, cut immediately after return. The model sees the function and nothing else: no primer, no examples. From the softmax at that position the table reports P( result) and P( ans), the two candidate names. Each is a single token with its leading space. The answer is the top-1 over the whole vocabulary, not just the two candidates. That is why (,  round and  price appear in the P1 top-k above: the names compete inside a real next-token distribution.
  • Name sweep (18 names × 4 shapes). Same forward pass, with the accumulator replaced by each of 18 single-token names and ans kept as the distractor. A case is ok if the top-1 token is the name the binding account predicts (the accumulator, or the distractor under never-bound). An exact tie is counted a MISS, for the same reason the companion pages score ties as neither: argmax would break it toward whichever candidate is listed first. The margin, P(predicted) − P(other), is reported, not thresholded.
  • Attribution (per-logit aggregates). A magnitude, not a verdict. For each graph, every node sitting on a name's token positions is summed by its direct effect on one named logit. The graph carries every logit whose cumulative probability is needed to reach 0.95, so P2–P4 have two or three targets and P1 has one. A node's effect on the top logit and on the other logit are two different numbers, and both are reported.
  • Intervention. The same forward pass with one feature clamped to a fraction of its value at every position it fires, then the same two probabilities read off. The harness reports its unmodified baseline first. It must reproduce the behaviour probe (95.31 / 69.14 / 6.45%) before any intervention number is believed.
PPromptBindingRuns?  result ans
P1return-variable result bound twiceyes 95.70%0.00%
P2split-binding ans bound at line 2, result in the loopno 68.75%25.20%
P3result-never-bound ans bound twice, result read onlyno 6.45%89.06%
P4two-valid-locals both bound, only result growsyes 82.42%11.18%

Binding clearly matters to the behaviour: move it and the answer moves with it, from 95.7% down to 6.5%. The question is how, and for that the graphs have to be read carefully.

Every number needs a named logit

A direct effect is meaningless until you say which logit it is measured against. The first prompt's graph has a single logit target, so there is nothing there to get wrong. The controls each carry several, because both names clear the coverage threshold. A node's effect on the top logit can be near zero while its effect on the other logit is large. Every number below names its target.

For each graph, take every node sitting on a name's tokens and sum its direct effect on each logit separately. P2, P3 and P4 each have two candidate names. Two more graphs, split-total and total-never-bound, repeat P2 and P3 with total as the accumulator. They check that nothing here is about the word result:

drive on its own name's logit drive on the other name's logit ★ = the name the model actually returned
Each name's nodes drive only that name's logit Ten horizontal bar pairs, one pair per name pathway across five graphs. The upper bar of each pair, the drive on that name's own logit, runs from 17.0 to 38.9. The lower bar, the drive on the rival name's logit, is invisible at this scale: every one falls between minus 0.64 and plus 2.00. 0 10 20 30 40 P2 split-binding result ★ split-binding · result nodes → the “result” logit: +26.963 +26.96 split-binding · result nodes → the “ans” logit: +1.389 ans split-binding · ans nodes → the “ans” logit: +20.268 +20.27 split-binding · ans nodes → the “result” logit: +0.049 P3 result-never-bound ans ★ result-never-bound · ans nodes → the “ans” logit: +26.957 +26.96 result-never-bound · ans nodes → the “result” logit: +0.700 result result-never-bound · result nodes → the “result” logit: +17.027 +17.03 result-never-bound · result nodes → the “ans” logit: +0.921 P4 two-valid-locals result ★ two-valid-locals · result nodes → the “result” logit: +37.301 +37.30 two-valid-locals · result nodes → the “ans” logit: +1.996 ans two-valid-locals · ans nodes → the “ans” logit: +21.295 +21.30 two-valid-locals · ans nodes → the “result” logit: -0.206 split-total total ★ split-total · total nodes → the “total” logit: +38.883 +38.88 split-total · total nodes → the “ans” logit: -0.641 ans split-total · ans nodes → the “ans” logit: +19.000 +19.00 split-total · ans nodes → the “total” logit: +0.060 total-never-bound ans ★ total-never-bound · ans nodes → the “ans” logit: +28.040 +28.04 total-never-bound · ans nodes → the “total” logit: +0.657 total total-never-bound · total nodes → the “total” logit: +20.838 +20.84 total-never-bound · total nodes → the “ans” logit: -0.277 every cross term falls in this band: −0.64 to +2.00
Per-logit aggregate direct effect, summed over every node at that name's token positions, in the default-threshold graphs (node 0.8 / edge 0.98). The off-diagonal terms are noise: no pathway moves the rival logit by more than 2.0 while moving its own by 17 to 39.
GraphReturned→ own logit→ other Loser→ own logit→ other
P2 split-bindingresult +26.963+1.389 ans+20.268+0.049
P3 result-never-boundans +26.957+0.700 result+17.027+0.921
P4 two-valid-localsresult +37.301+1.996 ans+21.295−0.206
split-total (P2, total)total † +38.883−0.641 ans+19.000+0.060
total-never-bound (P3, total)ans +28.040+0.657 total †+20.838−0.277

† The total pathway includes the total token inside the function name calculate_total. The result prompts have no equivalent. That occurrence is worth +7.50 of split-total's +38.883 and +5.15 of total-never-bound's +20.838. Drop it and they read +31.380 and +15.689. Either way the cross terms stay near zero, which is what this table is about. But the two total rows are not directly comparable to the three result rows. The confound is itself a small result: a name's pathway picks the name up wherever it occurs, including in an identifier that merely contains it.

Each variable name has its own pathway to its own logit, and the pathways are orthogonal. Binding does not switch a pathway on or off. It sets how much drive that name accumulates. The model then does a winner-take-all comparison at the logit layer.

What the graphs show

  • The features are tied to the name, not to the act of binding. 18_4769 does not respond to ans = 0 at all, and that is a perfectly good binding. A different feature, 18_4732, is the one that fires there.
  • A losing pathway is still fully routed. In P3, 18_4769 fires once (position 25, activation 92.5) and is worth +8.50 to  result. Its largest firing in P2 (position 23, activation 91.0) is worth +8.94. Matched activation, matched effect, whether its name wins or loses. Likewise 18_4732 at activation 39.0 gives  ans +1.555 in one graph and +1.305 in the other: the same feature doing the same job. What changes between the graphs is how much total drive each name accumulates, not what any one feature does.
  • The ordering is consistent; the magnitude is not. The winning pathway's total runs 26.96 / 26.96 / 37.30 / 38.88 / 28.04 across the five graphs, a spread of 11.9. There is no fixed drive budget. What holds in every graph is that the winner's own-logit drive exceeds the loser's. The winner also recruits more nodes (259 > 238, 500 > 102, 335 > 188, 560 > 183, 509 > 313), a trend rather than a mechanism.
  • Part of the drive is name-agnostic. 24_12518_30, on the return token, gives +1.680 to  result and +1.781 to  ans in P2. That is a baseline "a local variable goes here" push that belongs to neither name's pathway.
  • Suppression is per-pathway, not global. 24_15424 presses on  result even where  result has already lost and sits at 6.5%, so it is not reacting to the outcome. Swapping the accumulator's name swaps the suppressing feature. Its own subsection, below.
  • Attribution and ablation agree, independently. Deleting 18_4732 from P3 moves  ans only 89.06% → 86.72%. That is small, and exactly what the graph predicts: ans's drive sits mostly in its raw embeddings (+5.688 and +4.906) rather than in that feature (+1.305).

Each pathway has its own suppressor

The largest negative node in the first graph, 24_15424_30, could be a global calibrator that presses on whichever name is winning. Or it could be something specific to result. Swapping the accumulator for total answers that: each name's pathway is opposed by its own dedicated layer-24 feature at the return position. Rank the negative nodes into each pathway's own logit, as a fraction of that pathway's drive:

PathwaySuppressorMagnitude ÷ pathway drive, per graph
result 24_15424 0.285 / 0.263 / 0.240 / 0.292
total 24_12843 0.285 / 0.288
ans none above 0.15 largest negatives 0.06–0.10

The load-bearing fact is qualitative, and it needs no ratio to state: change the accumulator from result to total and the suppressing feature index changes with it. Both are layer 24, both sit on return, but they are different features. One global calibrator would be the same feature in both.

The 0.24–0.29 band is an observation, not a law. Six points across two names is not enough to call a ratio fixed, so it is reported and not leaned on. The claim rests on the feature indices, not on the ratio.

For ans the honest phrasing is "not captured", not "does not exist". Its drive sits in its raw embeddings (+7.375 in P4) rather than in its feature (+1.727). The transcoders cover that pathway poorly, so its partner is presumably just not represented in the graph.

In P4 the pairing is at its most dramatic: 18_4769_16 drives  result at +10.562 while 24_15424_36 opposes it at −10.875. Those are the two largest-magnitude nodes anywhere in this project, and they very nearly cancel.

A trap in reading the subgraphs

In P3 the pinned subgraph says the opposite of the outcome. The handful of pinned nodes give result +12.8 against ans +11.9. Yet the model answers  ans at 89.1%. The true ordering appears only once every node at those positions is summed: +26.957 against +17.027. ans wins on breadth, not on peak: 500 nodes against 103.

So any subgraph figure has to carry the aggregate totals beside it, or a reader will draw the opposite conclusion from the picture. That is the general lesson rather than a quirk of this prompt. A pinned subgraph is a hypothesis about which nodes matter, and the aggregate is the check on it.

All four prompts' graphs are hosted on Neuronpedia with the relevant nodes pinned and every logit target pinned beside them. Each supernode label names the logit its total is measured against. The P2 and P3 labels give both the pinned total and the all-nodes aggregate, so the P3 subgraph cannot be read as saying result wins. The hosted copies are pruned harder than the graphs the table above is computed from, so their totals run somewhat lower.

P4 is the one to start with. It is the valid Python among them. It separates the two pathways most cleanly (the pinned pathway groups cross to the rival logit at just +0.29 and +0.24), and it holds the near-cancelling pair described above. In its pinned view the suppressor 24_15424_36 is deliberately left outside every group, so it reads as what it is: the single dominant opposer, not part of a distributed background. All three logit nodes are pinned, including ( at 1.3%. The two names are competing inside a real distribution, not a two-way choice we constructed.

The behaviour table comes from a direct probe of the model. The attribution runs read it in bfloat16 and report 95.3 / 69.1 / 89.1 / 82.4% for P1–P4. That is where the logit probabilities in the graphs come from. Raw reports: P1, P2, P3, P4.

One number is deliberately missing from the aggregate table: P1's. Summed over its two result writes it is +17.709. But its top prediction cleared --desired-logit-prob 0.95 on its own, so its graph has a single logit target and a different node set. It is not comparable to the others. Putting it in the same column would invite exactly the apples-to-oranges reading this section is about.

Is any of this about the word result?

No. Sweeping 18 accumulator names through four prompt shapes, against the same distractor ans, the binding account predicts the top-1 token in 71 of 72 cases. total, buf, tally, subtotal, running and accum behave like result does.

one accumulator name the one miss
18 accumulator names across four prompt shapes Four rows of dots, one row per prompt shape, 18 dots each. Margins cluster near 95% for the consistent shape, near 89% for never-bound, near 60% for two-valid, and spread from 0 to 61% for split. One open circle at exactly zero marks the single miss. 0% 25% 50% 75% 100% consistent A bound twice runnable Python result: P(acc) 96.09%, P(ans) 0.00%, margin +96.09 total: P(acc) 97.27%, P(ans) 0.00%, margin +97.27 acc: P(acc) 97.27%, P(ans) 0.00%, margin +97.27 out: P(acc) 95.70%, P(ans) 0.00%, margin +95.70 count: P(acc) 94.14%, P(ans) 0.00%, margin +94.14 buf: P(acc) 96.48%, P(ans) 0.00%, margin +96.48 value: P(acc) 92.58%, P(ans) 0.00%, margin +92.58 tmp: P(acc) 91.41%, P(ans) 0.00%, margin +91.41 res: P(acc) 96.09%, P(ans) 0.00%, margin +96.09 agg: P(acc) 95.70%, P(ans) 0.00%, margin +95.70 score: P(acc) 93.75%, P(ans) 0.00%, margin +93.75 data: P(acc) 92.58%, P(ans) 0.00%, margin +92.58 accum: P(acc) 92.58%, P(ans) 0.00%, margin +92.58 output: P(acc) 96.88%, P(ans) 0.00%, margin +96.88 running: P(acc) 97.27%, P(ans) 0.00%, margin +97.27 tally: P(acc) 93.36%, P(ans) 0.00%, margin +93.36 subtotal: P(acc) 92.58%, P(ans) 0.00%, margin +92.58 amount: P(acc) 95.31%, P(ans) 0.00%, margin +95.31 18/18 two-valid both bound, only A grows runnable Python result: P(acc) 82.42%, P(ans) 11.18%, margin +71.24 total: P(acc) 79.69%, P(ans) 13.87%, margin +65.82 acc: P(acc) 69.14%, P(ans) 22.46%, margin +46.68 out: P(acc) 72.27%, P(ans) 20.70%, margin +51.57 count: P(acc) 68.75%, P(ans) 19.73%, margin +49.02 buf: P(acc) 81.25%, P(ans) 14.06%, margin +67.19 value: P(acc) 85.55%, P(ans) 7.96%, margin +77.59 tmp: P(acc) 75.00%, P(ans) 14.75%, margin +60.25 res: P(acc) 67.19%, P(ans) 24.71%, margin +42.48 agg: P(acc) 77.73%, P(ans) 7.23%, margin +70.50 score: P(acc) 73.05%, P(ans) 18.46%, margin +54.59 data: P(acc) 76.56%, P(ans) 11.77%, margin +64.79 accum: P(acc) 65.62%, P(ans) 18.85%, margin +46.77 output: P(acc) 85.16%, P(ans) 8.94%, margin +76.22 running: P(acc) 89.06%, P(ans) 5.03%, margin +84.03 tally: P(acc) 67.19%, P(ans) 19.34%, margin +47.85 subtotal: P(acc) 64.84%, P(ans) 16.31%, margin +48.53 amount: P(acc) 67.19%, P(ans) 19.24%, margin +47.95 18/18 never-bound A only ever read invalid Python (control) result: P(acc) 7.23%, P(ans) 88.28%, margin +81.05 total: P(acc) 5.15%, P(ans) 91.41%, margin +86.26 acc: P(acc) 1.73%, P(ans) 94.92%, margin +93.19 out: P(acc) 1.94%, P(ans) 93.36%, margin +91.42 count: P(acc) 1.05%, P(ans) 94.53%, margin +93.48 buf: P(acc) 7.23%, P(ans) 87.89%, margin +80.66 value: P(acc) 2.20%, P(ans) 93.36%, margin +91.16 tmp: P(acc) 8.11%, P(ans) 87.11%, margin +79.00 res: P(acc) 6.45%, P(ans) 89.06%, margin +82.61 agg: P(acc) 0.38%, P(ans) 93.75%, margin +93.37 score: P(acc) 1.51%, P(ans) 93.75%, margin +92.24 data: P(acc) 1.51%, P(ans) 93.36%, margin +91.85 accum: P(acc) 0.93%, P(ans) 95.31%, margin +94.38 output: P(acc) 5.71%, P(ans) 89.06%, margin +83.35 running: P(acc) 3.15%, P(ans) 92.19%, margin +89.04 tally: P(acc) 0.93%, P(ans) 94.92%, margin +93.99 subtotal: P(acc) 1.18%, P(ans) 93.75%, margin +92.57 amount: P(acc) 1.50%, P(ans) 92.97%, margin +91.47 18/18 split D bound, A grows in loop invalid Python (control) result: P(acc) 71.09%, P(ans) 23.14%, margin +47.95 total: P(acc) 78.12%, P(ans) 17.48%, margin +60.64 acc: P(acc) 50.39%, P(ans) 44.34%, margin +6.05 out: P(acc) 64.45%, P(ans) 30.27%, margin +34.18 count: P(acc) 47.66%, P(ans) 42.19%, margin +5.47 buf: P(acc) 62.11%, P(ans) 29.49%, margin +32.62 value: P(acc) 65.62%, P(ans) 27.34%, margin +38.28 tmp: P(acc) 61.72%, P(ans) 29.10%, margin +32.62 res: P(acc) 63.67%, P(ans) 30.08%, margin +33.59 agg: P(acc) 55.47%, P(ans) 33.79%, margin +21.68 score: P(acc) 44.92%, P(ans) 44.92%: exact tie, counted a miss score: exact tie, counted a miss data: P(acc) 53.12%, P(ans) 36.72%, margin +16.40 accum: P(acc) 51.95%, P(ans) 40.43%, margin +11.52 output: P(acc) 75.78%, P(ans) 19.14%, margin +56.64 running: P(acc) 66.41%, P(ans) 24.41%, margin +42.00 tally: P(acc) 45.31%, P(ans) 40.04%, margin +5.27 subtotal: P(acc) 55.86%, P(ans) 29.88%, margin +25.98 amount: P(acc) 46.48%, P(ans) 41.02%, margin +5.46 17/18 margin: P(name the binding account predicts) − P(the other name), one dot per accumulator name
Each dot is one accumulator name. Position is the margin between the name the binding account predicts and the other candidate. A dot to the right of the axis means the account was right. The two upper shapes are ordinary runnable Python.
ShapeRunnable?Top-1 correctMean margin
consistentyes18/18+94.8%
two-validyes18/18+59.6%
never-boundno, control18/18+89.0%
splitno, control17/18+26.5%

The one miss is reported as a miss. Under split, score lands on 44.92% against 44.92%, an exact tie in bfloat16. The argmax breaks it toward  ans. A tie is not a win, so it is counted as a miss.

Note also which shape is hardest. split has the smallest margins because it is the most conflicted prompt: one name is bound, a different one is accumulated into. That is the shape the pathway account predicts should be close, and it is.

This is also the answer to the obvious objection. Two of the control prompts are invalid Python, and a Python audience is right to be suspicious of conclusions drawn from code that cannot run. But two-valid is ordinary runnable code: both locals bound, one of them accumulated into. It is 18/18 with a mean margin of +59.6%. The effect does not depend on the prompt being broken.

Turning one pathway down

Everything above is correlational: the attribution graph says which nodes are associated with which logit. The direct test is to intervene. Clamp feature 18_4769 (the result feature) to a fraction of its value at every position it fires, and re-run the forward pass. The answer moves smoothly and then flips.

P( result) P( ans) prompt: P2 split-binding. Solid, filled: attention frozen. Dashed, hollow: attention re-routes freely
Scaling one name's feature down flips the prediction; amplifying the other backfires Two panels. On the left, scaling the result feature from full strength down to zero lowers the probability of result from 69% to 24% and raises ans from 25% to 65%; the two cross near a gain of 0.35. A dashed pair shows the same sweep with attention free to re-route: result keeps 40% at zero and ans reaches 51%, and the two cross only at zero. On the right, amplifying the ans feature up to 32 times does not raise ans at all. Past about 6 times it collapses to under 3%. Scale the ‘result’ feature DOWN 0% 25% 50% 75% 100% 1 0.8 0.6 0.4 0.2 0 scaled to 1 → P(“ result”) = 69.14% scaled to 0.9 → P(“ result”) = 66.41% scaled to 0.8 → P(“ result”) = 63.67% scaled to 0.7 → P(“ result”) = 58.20% scaled to 0.6 → P(“ result”) = 55.08% scaled to 0.5 → P(“ result”) = 51.95% scaled to 0.4 → P(“ result”) = 45.70% scaled to 0.3 → P(“ result”) = 45.90% scaled to 0.2 → P(“ result”) = 39.65% scaled to 0.1 → P(“ result”) = 33.98% scaled to 0 → P(“ result”) = 24.02% scaled to 1 → P(“ ans”) = 25.39% scaled to 0.9 → P(“ ans”) = 27.54% scaled to 0.8 → P(“ ans”) = 30.08% scaled to 0.7 → P(“ ans”) = 35.35% scaled to 0.6 → P(“ ans”) = 37.89% scaled to 0.5 → P(“ ans”) = 40.43% scaled to 0.4 → P(“ ans”) = 45.70% scaled to 0.3 → P(“ ans”) = 45.90% scaled to 0.2 → P(“ ans”) = 50.78% scaled to 0.1 → P(“ ans”) = 55.86% scaled to 0 → P(“ ans”) = 65.23% scaled to 1, attention free → P(“ result”) = 69.14% scaled to 0.9, attention free → P(“ result”) = 66.41% scaled to 0.8, attention free → P(“ result”) = 66.41% scaled to 0.7, attention free → P(“ result”) = 63.67% scaled to 0.6, attention free → P(“ result”) = 60.94% scaled to 0.5, attention free → P(“ result”) = 60.94% scaled to 0.4, attention free → P(“ result”) = 57.81% scaled to 0.3, attention free → P(“ result”) = 51.95% scaled to 0.2, attention free → P(“ result”) = 49.02% scaled to 0.1, attention free → P(“ result”) = 45.90% scaled to 0, attention free → P(“ result”) = 39.65% scaled to 1, attention free → P(“ ans”) = 25.39% scaled to 0.9, attention free → P(“ ans”) = 27.54% scaled to 0.8, attention free → P(“ ans”) = 27.54% scaled to 0.7, attention free → P(“ ans”) = 30.08% scaled to 0.6, attention free → P(“ ans”) = 32.42% scaled to 0.5, attention free → P(“ ans”) = 32.62% scaled to 0.4, attention free → P(“ ans”) = 34.96% scaled to 0.3, attention free → P(“ ans”) = 40.62% scaled to 0.2, attention free → P(“ ans”) = 43.16% scaled to 0.1, attention free → P(“ ans”) = 45.90% scaled to 0, attention free → P(“ ans”) = 50.78% Amplify the ‘ans’ feature UP 1 2 4 8 16 32 gain ×1 → P(“ result”) = 69.14% gain ×1.5 → P(“ result”) = 68.75% gain ×2 → P(“ result”) = 66.80% gain ×3 → P(“ result”) = 66.41% gain ×4 → P(“ result”) = 66.02% gain ×6 → P(“ result”) = 65.23% gain ×8 → P(“ result”) = 71.88% gain ×12 → P(“ result”) = 78.52% gain ×16 → P(“ result”) = 82.42% gain ×24 → P(“ result”) = 85.16% gain ×32 → P(“ result”) = 85.94% gain ×1 → P(“ ans”) = 25.39% gain ×1.5 → P(“ ans”) = 25.39% gain ×2 → P(“ ans”) = 27.73% gain ×3 → P(“ ans”) = 27.54% gain ×4 → P(“ ans”) = 27.54% gain ×6 → P(“ ans”) = 27.15% gain ×8 → P(“ ans”) = 20.61% gain ×12 → P(“ ans”) = 12.01% gain ×16 → P(“ ans”) = 7.67% gain ×24 → P(“ ans”) = 4.22% gain ×32 → P(“ ans”) = 2.60% tie → flip 24.0% 65.2% 39.7% free 50.8% free 85.9% 2.6% gain on 18_4769 (the ‘result’ feature) gain on 18_4732 (the ‘ans’ feature)
Left: the intervention that works. Right: the same experiment run in the other direction, which does not. It is worth showing rather than dropping.
Gain on 18_47691.00.80.60.40.30.20.0
 result69.14%63.67%55.08%45.70%45.90%39.65%24.02%
 ans25.39%30.08%37.89%45.70%45.90%50.78%65.23%
 result, attention free69.14%66.41%60.94%57.81%51.95%49.02%39.65%
 ans, attention free25.39%27.54%32.42%34.96%40.62%43.16%50.78%

The response is monotonic in  ans throughout. The two candidates tie exactly at gain 0.4 and again at 0.3, and from 0.2 down  ans is ahead. Turning one name's volume down changes which variable the model returns. That is the claim, tested directly rather than inferred from the graph.

The solid curves freeze every head's attention pattern at its baseline value, so the intervention changes only what the heads carry. That is the setting the attribution graph itself assumes, and the one the ablation numbers above use. The dashed curves let the perturbation propagate normally, so downstream heads may re-route in response, and the network compensates for part of the loss: at zero  result keeps 39.65% rather than 24.02%, the two candidates tie at 0.1 (45.90% each), and  ans is ahead only at 0.0. The slope is gentler, the flip survives. The conclusion does not rest on freezing attention.

The baseline forward pass in this harness reproduces the probe exactly (95.31 / 69.14 / 6.45%), so the pipeline was verified before any of the intervention numbers were believed. Ablating outright, rather than scaling, does the same thing: removing 18_4769 from P2 takes  result from 69.14% to 24.02% and hands the argmax to  ans.

Why only scaling down counts

Amplifying a feature does not do the mirror image of ablating it. Scaling the losing ans feature up should, on a naive reading, raise  ans. It does not. Past about 6× it collapses: 27.2% at 6×, 7.7% at 16×, 2.6% at 32×, while  result climbs to 85.9%.

This is not a property of the pathway. It is the residual stream being pushed off the distribution the later layers were trained on, so the output falls back toward the prior. Scaling down stays inside the feature's natural range, and it is the intervention this page relies on. The amplification sweep is shown so the asymmetry is on record.

A mechanism this reliable invites a stronger reading than it has earned: that the model knows Python's binding rules. It does not. The pathways track which name carries how much drive, not when a name is resolved. Two companion pages probe that boundary with executed ground truth and paired prompts:

  • Right name, wrong rules: ten classic binding traps (closure capture, mutable defaults, loop-variable leak, class-body scope), posed as near-identical pairs with opposite answers. The score is 0 of 4 pairs, with the two scoping rules exactly swapped in one pair.
  • The expression is not enough: PEP 289 generator-expression late binding, where the model is worse on the sugared form of the identical program. Plus four chapters (float width, set order, bound-method identity, interpreter version) where the printed expression underdetermines the answer. The score is 1 of 13 pairs.

A prompt design that does not work here

Cutting mid-identifier is not a usable probe on this model. A natural first design is to cut the prompt at return res and watch the model complete res → ult. That premise does not hold on gemma-2-2b, which is why the prompts above all cut at return instead.

Probed directly, the top prediction after  res is a newline (29.5%): the model accepts res as a complete variable name. And because  result and result are each a single token in Gemma's vocabulary, there is no high-probability res → ult BPE split to observe at all. The top identifier continuations are junk like ut and lut.

The attribution graph for that variant says the same thing from the inside. Its strongest direct effects are transcoder error nodes at the  res position (E_23_31, E_19_31), that is, behaviour the transcoders do not explain. High error-node attribution is a warning sign that the graph is not describing a clean circuit.

The practical lesson, and the reason it is on this page: probe the tokenizer and the top-k predictions before spending GPU time on attribution. A prompt design can be perfectly reasonable as Python and still be unanswerable as a next-token problem.

Explore the graph

The full attribution graph is hosted here, running Anthropic's attribution-graph viewer against the pruned graph for this prompt. It opens with the circuit described above already pinned into the Subgraph pane at the bottom. The three firings of 18_4769 are grouped as one supernode, and the layer-21 to layer-25 features on return as another. The suppressor, the two result embeddings and the target logit sit alongside them.

From there, click any node to see its inputs and outputs, Ctrl/Cmd-click to pin or unpin nodes, and hold G while clicking to group pinned nodes into a supernode of your own.

Open the interactive graph →

Three caveats. The page loads a 4.5 MB graph file and pulls its plotting libraries from transformer-circuits.pub and unpkg, so it needs a live connection and a moment to start.

The feature-detail panel on the right stays empty. It fetches example activations from Anthropic's own feature store, which hosts their scans and not GemmaScope. There is no public equivalent to point it at. Generating those examples means a GPU pass over a corpus, which is outside this experiment. Feature identifiers follow {layer}_{feature}_{position}. Look a feature up on Neuronpedia to see what it responds to.

Finally, the node labels are mine, not the model's. GemmaScope transcoders ship without auto-interpretation text. So every feature node in the published graph is labelled with what is verifiable (its layer, feature index, the token it fires on and its activation) and nothing about what the feature means. The interpretive claims on this page come from the attribution numbers, not from the labels.

The graph hosted here is the first prompt's. On its own it shows the circuit but not what the circuit is tracking. That needs the controls. It does have one advantage: a single logit target, so a node's direct effect is unambiguous here in a way it is not in the controls. Read it alongside the Neuronpedia graphs for those.

Limitations and future work

One model. Everything here is gemma-2-2b. That is a constraint of the method before it is a choice. Attribution graphs need a full set of transcoders for the model, and GemmaScope's are the most complete public set. They are the ones circuit-tracer was built and validated on, and the only ones Neuronpedia can host graphs and run steering for. The alternatives are also much larger: the Llama-3.2 1B CLTs are 25 GB, Qwen-3 1.7B's 71 GB, GPT-OSS 20B's 264 GB, against 7.4 GB here, on a single RTX 3090. No cross-model claim is made. The result is a mechanism in one model, verified causally in that model.

Transcoder coverage is uneven. The ans pathway's drive sits in raw embeddings rather than in a feature, and it has no suppressor above 0.15 of its drive. "Not captured" is the honest reading. The transcoders explain that pathway less well than the result and total ones, and an embedding-led pathway is harder to intervene on cleanly.

Two of the four prompts are invalid Python. P2 and P3 read a name before it is bound, because that is the only way to make binding and occurrence disagree. P4 and the two-valid sweep (18/18) show the effect does not depend on it, but the attribution numbers for P2 and P3 are numbers about code that cannot run.

Single-token cuts and a pruned view. Every prompt is decided by one next-token distribution, which probes recognition, not generation. The per-logit totals are computed from graphs pruned at node 0.8 / edge 0.98, and the hosted copies at 0.7 / 0.9, so absolute magnitudes shift with the threshold. The orderings and the near-zero cross terms are what is stable. The self-hosted viewer's feature-detail panel is empty, so what each feature responds to elsewhere has to be looked up on Neuronpedia.

Next. The obvious second model is Llama-3.2 1B: its CLTs exist, the model fits the same GPU without offload, and the attribution driver runs unchanged on the same backend. The behavioural probes (the four prompts, the 18-name sweep, the interventions' baseline) need no transcoders and are the first thing to run. Start with whether  result and  ans are single tokens in that vocabulary, which the whole design depends on. Beyond that: attribution on the total graphs' suppressor, to see whether per-name suppression is a general pattern or a feature of a few names, and a proper feature-detail panel for the hosted viewer.

Reproducing this

Everything below runs against the public safety-research/circuit-tracer release. You will need a GPU with roughly 10 GB free in bfloat16 (18 GB in float32). You also need a Hugging Face account that has accepted the Gemma licence, since the first run downloads about 18 GB of weights and transcoders.

git clone https://github.com/safety-research/circuit-tracer
cd circuit-tracer

uv venv .venv --python 3.11
uv pip install "torch==2.7.1" --index-url https://download.pytorch.org/whl/cu126
uv pip install .

.venv/bin/huggingface-cli login    # gemma-2-2b is a gated model

Then run the attribution:

.venv/bin/circuit-tracer attribute \
    -m google/gemma-2-2b \
    -t gemma \
    -p "$(printf 'def calculate_total(prices):\n    result = 0\n    for price in prices:\n        result = result + price\n    return')" \
    -o py-return-variable.pt \
    --dtype bfloat16 \
    --offload cpu \
    --batch_size 128 \
    --max_feature_nodes 8192 \
    --slug py-return-variable \
    --graph_file_dir graph_files \
    --node_threshold 0.8 \
    --edge_threshold 0.98 \
    --verbose

That writes graph_files/py-return-variable.json alongside the raw .pt graph. To view it locally, add --server to the command above, or serve an existing directory:

.venv/bin/circuit-tracer start-server --graph_file_dir graph_files --port 8041

On thresholds. At the defaults (node 0.8, edge 0.98) the graph JSON is 76 MB, which is fine for the local viewer and unwieldy for anything else. The graph published on this page was re-pruned from the same saved .pt at node 0.7 / edge 0.9, giving 4.5 MB. Re-pruning does not require rerunning attribution, but it does still need the GPU.

Do not use float16. gemma-2's activation magnitudes overflow in fp16. Use bfloat16 on Ampere or newer, and float32 on older cards. This is why the run above pins --dtype bfloat16 rather than letting a generic half-precision default apply.

The control prompts run through the same command. Only -p and --slug change. Their per-logit numbers are read straight out of the graph JSON: for each node whose feature_type is logit, sum the links whose target is that node. Take every logit node, not just the first. On the first prompt there is only one, so reading the top logit alone happens to be correct there and silently wrong on the controls.

The 18-name sweep and the interventions need no transcoders and no attribution run. They are plain transformers forward passes, which is why they were used to check the graph rather than the other way round. The intervention harness clamps a feature through the transcoder at every position it fires and re-runs the model. It reports its unmodified baseline first, so a broken hook cannot pass silently.

The raw attribution reports behind the tables above are published here: py-return-variable_report.json, py-two-valid-locals_report.json, py-split-binding_report.json, py-result-never-bound_report.json.

Credits

Method and tooling: Circuit Tracing: Revealing Computational Graphs in Language Models and On the Biology of a Large Language Model (Anthropic, 2025); the circuit-tracer library by Michael Hanna and Mateusz Piotrowski; GemmaScope transcoders (mwhanna/gemma-scope-transcoders). The interactive viewer bundled under /interpretability/practice/graph/ is Anthropic's attribution-graphs-frontend, MIT licensed, redistributed with its licence intact.