Use case
Generation
A forward that keeps going: decode: N, positions
along what the model said, and a write that fires at every step.
decode: N on an intervention makes every forward of that
intervention a prefill plus N greedy steps instead of one pass. With it a read or a
write may name the continuation frame —
{"frame": "generated", …} — a position along what this row generated,
rather than along the prompt it was given.
Two documents in the corpus use it, and between them they use the two halves of
the frame. generate_probe.json reads at a decode step, swept,
so each step is a point of the sweep. generated_answer.json reads at a
cut of the finished continuation — the last token of a word the model said
— which is a place a row may not have, and the run reports which rows had it.
At a glance #
$ uv run causalab-mini explain documents/v2/generate_probe.json
<root>: Plan
index=0: Plan
score: Observe metrics=['p_answer/token_prob'] saves=['p_answer.json', 'generated.safetensors']
forward 'original' on 'counterfactual' (4 rows x 11 tokens) decode=3
read 'v_cf' at block_output[0] pos={index:-1} via 'identity'
forward 'patched' on 'base' (4 rows x 11 tokens) decode=3
write 'patch' at block_output[0] pos={index:-1} swap(v_cf) via 'identity'
read 'logits' at lm_head @step 0 pos={generated index:0} via 'identity'
…
index=2: Plan
score: Observe metrics=['p_answer/token_prob'] saves=['p_answer.json', 'generated.safetensors']
forward 'original' on 'counterfactual' (4 rows x 11 tokens) decode=3
read 'v_cf' at block_output[0] pos={index:-1} via 'identity'
forward 'patched' on 'base' (4 rows x 11 tokens) decode=3
write 'patch' at block_output[0] pos={index:-1} swap(v_cf) via 'identity'
read 'logits' at lm_head @step 2 pos={generated index:2} via 'identity'
Three points, one per decode step. decode=3 is on both forwards,
because it is a property of the intervention and not of one read. The write is in
the prompt frame at pos={index:-1}, so it happens once, at the prefill;
the read is @step 2, two greedy tokens later.
The decode is a bound, not a stop #
A decoding forward is one generate trace with
max_new_tokens and min_new_tokens both set to
decode, and sampling off. Every row produces exactly N tokens and the
batch stays rectangular, which is what lets a step be a position at all: step 2 is
step 2 on every row.
Forcing the bound means a row that would have stopped does not. That is not
thrown away: the continuation's frame is cut at the row's first stop token, the
stop token is the last one in it, and a row that never stopped has the whole
bound and no eos segment. "Did the model stop?" comes home as a
reported reason on the row rather than as an exception that ends the run.
The character map the frame uses is built from the ids the decode produced, not by re-encoding the text it decoded to. A tokenizer is free to merge across a boundary the decode never saw, and then a span found in the re-encoded text would name tokens that never existed.
The two halves of the frame #
A read may name any cut of the continuation, because by the time the run scores it the whole continuation exists. A write happens during the decode, so it may only name a step the decode has already reached.
| position | what it names | read | write |
|---|---|---|---|
{"frame": "generated", "index": k}, k ≥ 0 |
decode step k | yes | yes |
{"frame": "generated", "all": true} |
as a write, every decode step; as a read, every token of the finished continuation — which is a window, so no metric scores it directly | yes | yes |
{"frame": "generated", "index": -1} |
the last token of the finished continuation | yes | no |
{"frame": "generated", "index": -1, "scope": {"variable": "c"}} |
the last token of where column c's text was said | yes | no |
{"frame": "generated", "index": -1, "scope": {"segment": "eos"}} |
the row's stop token | yes | no |
The bottom three are cuts of something that does not exist until the decode has finished. A read of one fires at every step and the stack is cut afterwards; a write is refused, by name:
$ uv run causalab-mini validate gen_write_last.json
Traceback (most recent call last):
…
pydantic_core._pydantic_core.ValidationError: 1 validation error for Spec
Value error, interventions.steer: write 'push': a write in the continuation frame is at a step the decode has reached — {'index': k} with k >= 0, or {'all': true}. {generated index:-1} names a cut of the finished continuation, which is something to read and not something to write into
Reading at a step: generate_probe #
documents/v2/generate_probe.json — the whole document
{
"header": {
"description": "Generation: the interchange at layer 0 of the prompt, then three greedy tokens, with the head read at each decode step \u2014 {'frame': 'generated', 'index': k} is the continuation frame, swept here so each step is a point. Does the patch, made once at the prefill, still move what the model says two tokens later? It reaches the continuation only through the cache. The generated ids come home as a result too."
},
"model": {
"key": "hf-internal-testing/tiny-random-LlamaForCausalLM",
"revision": "9fb191250dd56d0ba7ec9785a025ed29c03d5998",
"dtype": "fp32"
},
"roles": {
"base": {
"field": "input"
},
"counterfactual": {
"field": "counterfactual_inputs[0]"
}
},
"sites": {
"target": {
"component": "block_output",
"layers": [
0
]
},
"head": {
"component": "lm_head"
}
},
"interventions": {
"generate": {
"reads": {
"v_cf": {
"site": "target",
"pos": -1,
"model": "original",
"input": "counterfactual"
},
"logits": {
"site": "head",
"pos": {
"frame": "generated",
"index": {
"sweep": [
0,
1,
2
]
}
},
"model": "patched",
"input": "base"
}
},
"writes": {
"patch": {
"site": "target",
"pos": -1,
"mechanism": "swap",
"operand": "v_cf"
}
},
"models": {
"patched": {
"input": "base",
"writes": [
"patch"
]
}
},
"metrics": {
"p_answer": {
"kind": "token_prob",
"of": "logits",
"token": "cf_answer"
}
},
"decode": 3
}
},
"steps": {
"score": {
"kind": "observe",
"interventions": "generate",
"rows": {
"base": "weekdays/train",
"counterfactual": "weekdays/train"
},
"saves": [
{
"value": "p_answer",
"file_path": "p_answer.json"
},
{
"value": "patched.generated",
"file_path": "generated.safetensors"
}
]
}
}
}
The swept field is the read's index, inside the position:
"logits": {
"site": "head",
"pos": {"frame": "generated", "index": {"sweep": [0, 1, 2]}},
"model": "patched",
"input": "base"
}
so the points are named after the field the wrapper sits on, index,
and land in directories of that name:
$ uv run causalab-mini run documents/v2/generate_probe.json --device-map cpu --out out/gen
out/b-gen/document.json
out/b-gen/run.json
out/b-gen/index=0/document.json
out/b-gen/index=0/p_answer.json
out/b-gen/index=0/generated.safetensors
out/b-gen/index=1/document.json
out/b-gen/index=1/p_answer.json
out/b-gen/index=1/generated.safetensors
out/b-gen/index=2/document.json
out/b-gen/index=2/p_answer.json
out/b-gen/index=2/generated.safetensors
p_answer is token_prob of the cf_answer
column, per row, at that step. The four rows of
weekdays/train, one column per point:
| row | step 0 | step 1 | step 2 |
|---|---|---|---|
0 | 2.747e-05 | 3.009e-05 | 2.967e-05 |
1 | 3.412e-05 | 2.982e-05 | 3.160e-05 |
2 | 2.829e-05 | 3.266e-05 | 3.006e-05 |
3 | 2.827e-05 | 3.368e-05 | 2.937e-05 |
hf-internal-testing/tiny-random-LlamaForCausalLM has random
weights. Every probability here is about 3e-05, which is 1/32000 — the uniform
distribution over its vocabulary. The document is here for the mechanism; for a
result, run a documents/real/ document.
The written row says where it was read, and in the continuation frame that is the step, not an index into the prompt:
$ head -12 out/gen/index=2/p_answer.json
[
{
"example_id": "0",
"metric": "p_answer",
"value": 2.9670592994079925e-05,
"eligible": true,
"positions": [
2
],
"reason": "",
"unit": "probability",
…
The generated ids come home #
A decoding forward publishes one more value than a plain one:
<model>.generated, the ids it produced, prompt stripped. The
document saves patched.generated to a safetensors file, and it is a
[4, 3] int64 tensor — four rows, three steps. Decoded, all four rows of
this point are 'Handler substr energ': the weights are random, so the
continuation does not depend on the prompt.
The prompt-frame write reaches those steps only through the cache — nothing writes at step 1 or step 2. Measured against the same model with no write at all, the probabilities move by up to 1.4e-07 at step 0, where the write lands, and by up to 4.6e-09 at steps 1 and 2. Small, and not zero.
Reading where the model said something: generated_answer #
documents/v2/generated_answer.json — the whole document
{
"header": {
"description": "The continuation frame, read where the model said the row's own text. `said` is a word this checkpoint does say in its first six tokens for two of these rows and does not for the other two \u2014 the weights are random, so what it says is gibberish and the column was written to match it; on a real model `said` would be the answer. The read is {'frame': 'generated', 'index': -1, 'scope': {'variable': 'said'}}: the last token of that word in what this row generated. Whether the model said it at all is a reported result \u2014 two rows score and two come back `alignment_missing` \u2014 rather than an exception that ends the run. The second read asks where the row stopped; mini holds EOS off so the decode runs to its bound, so no row stops and every row says so."
},
"model": {
"key": "hf-internal-testing/tiny-random-LlamaForCausalLM",
"revision": "9fb191250dd56d0ba7ec9785a025ed29c03d5998",
"dtype": "fp32"
},
"roles": {
"base": {
"field": "input"
}
},
"sites": {
"head": {
"component": "lm_head"
}
},
"interventions": {
"say": {
"reads": {
"at_said": {
"site": "head",
"pos": {
"frame": "generated",
"index": -1,
"scope": {
"variable": "said"
}
},
"model": "original",
"input": "base"
},
"at_stop": {
"site": "head",
"pos": {
"frame": "generated",
"index": -1,
"scope": {
"segment": "eos"
}
},
"model": "original",
"input": "base"
}
},
"metrics": {
"p_answer": {
"kind": "token_prob",
"of": "at_said",
"token": "cf_answer"
}
},
"decode": 6
}
},
"steps": {
"score": {
"kind": "observe",
"interventions": "say",
"rows": {
"base": "weekdays/said"
},
"saves": [
{
"value": "p_answer",
"file_path": "p_answer.json"
}
]
}
}
}
Its dataset carries a said column, a word to look for in the
continuation:
$ uv run causalab-mini data weekdays/said
weekdays/said: 4 rows
columns: base_answer, cf_answer, example_id, input, said, split
splits: all
sample: {"example_id": "substr", "input": "If today is Thursday, tomorrow is", "said": "substr", "base_answer": " Friday", "cf_answer": " Sunday", "split": "all"}
Two reads, both at the head, both cuts of the finished continuation:
$ uv run causalab-mini explain documents/v2/generated_answer.json
<root>: Plan
score: Observe metrics=['p_answer/token_prob'] saves=['p_answer.json']
forward 'original' on 'base' (4 rows x 11 tokens) decode=6
read 'at_said' at lm_head over 6 steps pos={generated index:-1 scope:{variable:said}} via 'identity'
read 'at_stop' at lm_head over 6 steps pos={generated index:-1 scope:{segment:eos}} via 'identity'
over 6 steps is what a cut costs: the read cannot say which step it
wants until the decode has finished, so it fires at every one, keeps a value per
step, and the stack is cut against the continuation afterwards.
Keeping every step is rows × decode × width numbers, which is
nothing at decode: 6 on a 16-wide site and a gigabyte at
decode: 256 on a wide one. The compiler knows all three numbers
before any weights are loaded and refuses above 256 MiB, naming the three ways
out: name the step instead of a cut, decode fewer tokens, or score fewer rows in
one pass. --batch-size is not one of them — the count is the whole
pass's rows.
Six greedy tokens, and every row of this checkpoint produces the same six:
row said the six generated tokens
substr 'substr' 'Handler' 'substr' 'energ' 'calc' 'toss' 'orter'
energ 'energ' 'Handler' 'substr' 'energ' 'calc' 'toss' 'orter'
Friday 'Friday' 'Handler' 'substr' 'energ' 'calc' 'toss' 'orter'
Saturday 'Saturday' 'Handler' 'substr' 'energ' 'calc' 'toss' 'orter'
So two rows contain their own said word and two do not. That is the
point of the document: eligibility is per row, and it is a reported result.
$ uv run causalab-mini run documents/v2/generated_answer.json --device-map cpu --out out/said
out/b-genans/document.json
out/b-genans/run.json
out/b-genans/p_answer.json
$ cat out/said/p_answer.json
[
{
"example_id": "substr",
"metric": "p_answer",
"value": 3.008824569405988e-05,
"eligible": true,
"positions": [
1
],
"reason": "",
…
},
{
"example_id": "Friday",
"metric": "p_answer",
"value": null,
"eligible": false,
"positions": [],
"reason": "alignment_missing",
…
'substr' is the row's second generated token, so its position is
[1]; 'energ' is [2]. The two rows whose word
is not there get value: null, eligible: false and
reason: "alignment_missing" — a row of the table, not a gap in it, so
the miss can never be read as a zero or quietly shorten a denominator.
at_stop is {"frame": "generated", "index": -1, "scope":
{"segment": "eos"}} — the row's stop token. Because the decode is a bound
and EOS is held off, no row here stops, so no row has an eos segment
and all four resolve to alignment_missing. Nothing is saved from it;
it is in the document to show that "the model never stopped" is a position that
fails to resolve rather than an error.
Writing at every step: steering #
No document in documents/ writes in the continuation frame. This one
was written for this page and run here; it is not part of the corpus. It reads the
layer-0 residual at the prompt's last token from the un-written model, then adds it
back, scaled by 4, at every decode step of a second model.
generate_steer.json — authored for this page, not in documents/{
"header": {
"description": "Not in the corpus: a steering write in the continuation frame. The layer-0 residual at the prompt's last token is read from the un-written model and added back, scaled, at EVERY decode step of a second model - {'frame': 'generated', 'all': true} - so the write fires three times and not once. Both models are read at the same swept decode step, so the two p(answer) curves are directly comparable, and both continuations come home as ids."
},
"model": {
"key": "hf-internal-testing/tiny-random-LlamaForCausalLM",
"revision": "9fb191250dd56d0ba7ec9785a025ed29c03d5998",
"dtype": "fp32"
},
"roles": {
"base": {"field": "input"}
},
"sites": {
"target": {"component": "block_output", "layers": [0]},
"head": {"component": "lm_head"}
},
"interventions": {
"steer": {
"reads": {
"v_last": {"site": "target", "pos": -1, "model": "original", "input": "base"},
"clean_logits": {
"site": "head",
"pos": {"frame": "generated", "index": {"sweep": [0, 1, 2], "as": "step"}},
"model": "original",
"input": "base"
},
"steered_logits": {
"site": "head",
"pos": {"frame": "generated", "index": {"sweep": [0, 1, 2], "as": "step"}},
"model": "steered",
"input": "base"
}
},
"writes": {
"push": {
"site": "target",
"pos": {"frame": "generated", "all": true},
"mechanism": "add_scaled",
"operand": "v_last",
"params": {"scale": 4.0}
}
},
"models": {
"steered": {"input": "base", "writes": ["push"]}
},
"metrics": {
"p_clean": {"kind": "token_prob", "of": "clean_logits", "token": "cf_answer"},
"p_steered": {"kind": "token_prob", "of": "steered_logits", "token": "cf_answer"}
},
"decode": 3
}
},
"steps": {
"score": {
"kind": "observe",
"interventions": "steer",
"rows": {"base": "weekdays/train"},
"saves": [
{"value": "p_clean", "file_path": "p_clean.json"},
{"value": "p_steered", "file_path": "p_steered.json"},
{"value": "original.generated", "file_path": "clean.safetensors"},
{"value": "steered.generated", "file_path": "steered.safetensors"}
]
}
}
}
$ uv run causalab-mini explain generate_steer.json
<root>: Plan
step=0: Plan
score: Observe metrics=['p_clean/token_prob', 'p_steered/token_prob'] saves=['p_clean.json', 'p_steered.json', 'clean.safetensors', 'steered.safetensors']
forward 'original' on 'base' (4 rows x 11 tokens) decode=3
read 'v_last' at block_output[0] pos={index:-1} via 'identity'
read 'clean_logits' at lm_head @step 0 pos={generated index:0} via 'identity'
forward 'steered' on 'base' (4 rows x 11 tokens) decode=3
write 'push' at block_output[0] @step all pos={generated all} add_scaled(v_last, scale=4.0) via 'identity'
read 'steered_logits' at lm_head @step 0 pos={generated index:0} via 'identity'
step=1: Plan
score: Observe metrics=['p_clean/token_prob', 'p_steered/token_prob'] saves=['p_clean.json', 'p_steered.json', 'clean.safetensors', 'steered.safetensors']
forward 'original' on 'base' (4 rows x 11 tokens) decode=3
read 'v_last' at block_output[0] pos={index:-1} via 'identity'
read 'clean_logits' at lm_head @step 1 pos={generated index:1} via 'identity'
forward 'steered' on 'base' (4 rows x 11 tokens) decode=3
write 'push' at block_output[0] @step all pos={generated all} add_scaled(v_last, scale=4.0) via 'identity'
read 'steered_logits' at lm_head @step 1 pos={generated index:1} via 'identity'
step=2: Plan
score: Observe metrics=['p_clean/token_prob', 'p_steered/token_prob'] saves=['p_clean.json', 'p_steered.json', 'clean.safetensors', 'steered.safetensors']
forward 'original' on 'base' (4 rows x 11 tokens) decode=3
read 'v_last' at block_output[0] pos={index:-1} via 'identity'
read 'clean_logits' at lm_head @step 2 pos={generated index:2} via 'identity'
forward 'steered' on 'base' (4 rows x 11 tokens) decode=3
write 'push' at block_output[0] @step all pos={generated all} add_scaled(v_last, scale=4.0) via 'identity'
read 'steered_logits' at lm_head @step 2 pos={generated index:2} via 'identity'
@step all is the whole difference from
generate_probe, where the same site carried @step nothing
at all. A tap in the prompt frame applies at step 0, the prefill; a tap at
{"index": k} applies at step k; a tap at {"all": true}
applies at every step, prefill included.
Where it lands is decided per step, not compiled in: past the prefill the forward
sees one position, so the write goes to the last index of whatever the sequence axis
has. At the prefill that is the prompt's last token, which is where
v_last was read.
Both models are read at the same swept decode step, so the two columns below are the same rows, the same step, with and without the write:
| row | step 0, clean | step 0, steered | step 1, clean | step 1, steered | step 2, clean | step 2, steered |
|---|---|---|---|---|---|---|
0 | 2.735e-05 | 2.772e-05 | 3.009e-05 | 2.740e-05 | 2.967e-05 | 2.674e-05 |
1 | 3.412e-05 | 3.398e-05 | 2.982e-05 | 3.357e-05 | 3.160e-05 | 3.529e-05 |
2 | 2.843e-05 | 2.808e-05 | 3.266e-05 | 2.835e-05 | 3.007e-05 | 2.826e-05 |
3 | 2.837e-05 | 2.832e-05 | 3.368e-05 | 2.894e-05 | 2.937e-05 | 2.912e-05 |
At step 0 the write moves the probability in the fourth digit; at steps 1 and 2 it moves it in the second, because by then it has fired again. It also changes what the model says, which the two saved id tensors show directly:
original.generated [4598, 25148, 18190] 'Handler substr energ'
steered.generated [4598, 5448, 31926] 'Handler wurden𝓝'
Step 0 is unchanged and steps 1 and 2 are not. The write does fire at the prefill — the step-0 probabilities differ — but not by enough to move the argmax on this checkpoint.
What is refused #
| document | message |
|---|---|
a generated position with decode: 0 |
read 'clean_logits': a generated position needs `decode` > 0 |
| a read or write past the budget | write 'push': step 5 of a 3-token decode |
a write at {"index": -1} or at the eos cut |
a write in the continuation frame is at a step the decode has reached … |
a write with a scope |
write 'push': a write in the continuation frame takes no scope; … is only known once the decode has finished |
One more is refused a level down: {"scope": {"segment": "eos"}} in
the prompt frame. eos is a run of the generated frame, and a
prompt does not have one.
The other direction is refused too: a chat turn in the
generated frame. eos is the one segment name a frame
invents; every other one is a turn of the prompt, and a conversation names
it — see chat turns.
See also #
- Positions — the whole
Wherevocabulary the continuation frame is one field of, and whatalignment_missingis alongside - Reads, writes, metrics —
add_scaledand the other five mechanisms, and why a metric needs a one-position read - Sweeps — why sweeping the decode step gives one directory per step
- Layer sweep — the same swap write on a real model, where the numbers mean something