causalab-mini

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.

EOS is held off

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.

positionwhat it namesreadwrite
{"frame": "generated", "index": k}, k ≥ 0 decode step kyesyes
{"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 yesyes
{"frame": "generated", "index": -1} the last token of the finished continuationyesno
{"frame": "generated", "index": -1, "scope": {"variable": "c"}} the last token of where column c's text was saidyesno
{"frame": "generated", "index": -1, "scope": {"segment": "eos"}} the row's stop tokenyesno

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:

rowstep 0step 1step 2
02.747e-053.009e-052.967e-05
13.412e-052.982e-053.160e-05
22.829e-053.266e-053.006e-05
32.827e-053.368e-052.937e-05
these numbers mean nothing

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.

a cut has a size, and it is checked before the run

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.

the second read is the EOS cut

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, cleanstep 0, steered step 1, cleanstep 1, steered step 2, cleanstep 2, steered
02.735e-052.772e-053.009e-052.740e-052.967e-052.674e-05
13.412e-053.398e-052.982e-053.357e-053.160e-053.529e-05
22.843e-052.808e-053.266e-052.835e-053.007e-052.826e-05
32.837e-052.832e-053.368e-052.894e-052.937e-052.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 #

documentmessage
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 Where vocabulary the continuation frame is one field of, and what alignment_missing is alongside
  • Reads, writes, metrics — add_scaled and 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