Reference
Reads, writes, metrics
The parts of an intervention, and what each one computes.
An intervention declares four things and every step of a
document runs that same declaration: reads, which take a value
out of a forward; writes, which change one; models,
which say which writes a pass applies; and metrics, which turn a
read into one number per row.
All of it is pure data until an engine walks it. Two functions do the
tensor work: gather for a read, and one apply_write
that every mechanism and every featurizer goes through. That second one is the
seam the rest of the project is built around, and it is one line:
write = inverse(do(featurize(x)), err, x)
At a glance #
The closed vocabularies are printed by the vocab verb, which
needs no model, no weights and no network:
$ uv run causalab-mini vocab
components:
attention_query interior [heads]
…
logits nnterp logits
mechanisms: add_scaled, clamp, gaussian, lerp, renormalize, swap
featurizer kinds: gate, linear, pca, sae, subspace (plus 'identity', never declared)
metric kinds: match(expected), logit_diff(a, b), cross_entropy(target), token_logit(token), token_prob(token)
position forms: exactly one cut: index=int, span=tuple[int, int], last=int, all=bool
scope: variable=str, segment=Literal['system', 'user', 'assistant', 'eos']
frame: Literal['prompt', 'generated']; a bare integer is {"index": i}; -1 is the last token
The component list is Locations and the position forms are Positions. The three lines between them are this page.
A read #
A read names a place, a cut of the sequence, which pass to take it from, which text that pass runs on, and the space to express it in.
| field | default | what it is |
|---|---|---|
site | — | A name from sites: the component, its layer, and any heads or units of it. |
pos | — | The cut of the sequence. A bare integer is {"index": i}. |
model | "original" | "original", or the name of an entry in models — that is, the pass with those writes applied. |
input | — | A role, which is the column of the row the prompt comes from. |
featurizer | "identity" | A name from featurizers. The read is then the feature value, not the activation: v_cf is Qᵀx. |
view | "raw" | "logits" pushes a residual-stream read through the model's final norm and head. |
What it yields #
A read is a window of positions per row, and of that window the features
the site names. When every row's window is the same width the value keeps its
rectangle — (rows, w, width), with the unit window
w = 1 that a metric squeezes off. When the widths differ across
rows the value comes back flat, (total, width),
every row's positions in row order; the plan's own positions say
where each row begins and ends. Which shape it is, is decided by the
positions and never by the tensor.
width is the site's, not the model's: a site of four heads is
4 · head_dim wide and a site of four units is four wide. The
read's featurizer is applied where the read happens, so what a later write
gets as an operand is already in the feature space, and it is the same
featurizer object the write's inverse will use.
At one address, inside one pass, the writes go first and the reads see
them. A read that should not see them names model: "original",
which is a different forward.
view: "logits" #
With view: "logits" the gathered residual is pushed through
ln_final and lm_head inside the trace, and through
the family's logit softcapping where it has one (Gemma-2 does). The value is
(rows, w, vocab). A layer sweep of that read, with a
token_prob metric on it, is the logit lens as one document.
A logits view is only for the residual stream —
embeddings, block_input,
block_output, ln_final — because that is what the
final norm and head expect. And a featurized read cannot also be viewed as
logits: the projection takes an activation, not a feature vector.
The write contract #
Every write is the same five steps. Gather the activation at the site and
the positions; map it into the featurizer's space, keeping whatever the map
threw away as err; apply the mechanism to the feature value;
invert, handing inverse the error term and the pre-write
activation; scatter the result back.
err and x reach
inverse: they are what a partial write puts back. The identity
featurizer ignores both, which is what makes patching and DAS one code
path.With no featurizer key a write gets Identity:
featurize(x) is (x, None) and inverse
returns the mechanism's output untouched. The line collapses to "replace the
tensor", which is activation patching.
With a rotation it is DAS, and nothing else changes.
featurize is Qᵀx, the mechanism swaps that, and
inverse is Qf + (x − QQᵀx) — the complement comes
from x, the pre-write value, which is exactly why a subspace swap
leaves the orthogonal complement alone.
The whole seam is wrapped in torch.autocast(enabled=False)
and a featurizer declares fp32. A server may wrap a request in an autocast
region — NDIF does — and inside one a matmul of two fp32 tensors comes back
bf16 while a subtraction stays fp32, so half a Cayley solve is downcast and
linalg.solve refuses the pair.
Mechanisms #
Six, and each one's parameters are checked by name. mechanism
and operand are two fields rather than the protocol's
{"swap": "v_cf"}, because a key that is itself the mechanism's
name cannot be enumerated by a schema.
| mechanism | on the feature value f | operand | params |
|---|---|---|---|
swap | operand | required | none |
add_scaled | f + scale·operand | required | scale (default 1.0) |
lerp | (1−t)·f + t·operand | required | t, required |
gaussian | f + scale·ε, ε ~ N(0,1) | none | seed required, scale optional |
clamp | min(max(f, lo), hi) | none | lo, hi, at least one |
renormalize | f · ‖f₀‖ / ‖f‖ | supplied by the seam | none |
gaussian draws on the CPU generator and moves the noise, so
one seed is one noise tensor on any device. lerp at
t = 1 is a swap and at t = 0 is nothing; every
partial interchange is between. Of the six, the corpus uses swap,
add_scaled and renormalize; lerp,
gaussian and clamp have no document, and are covered
by tests/test_mechanisms.py.
The operand #
Four spellings, and the seam resolves them in five lines:
| spelling | what it is | a document that uses it |
|---|---|---|
"v_cf" | A read of this intervention, already in the write's feature space. | documents/v2/patching.json |
{"ref": "mean"} | A value an earlier sibling step published under outputs. | documents/v2/mean_ablation.json |
0.0 | A literal number. Zero ablation is this. | documents/v2/zero_ablation.json |
| absent | For gaussian, clamp and renormalize, which take none. | documents/v2/steer_renormalize.json |
A read used as an operand must be a read of the same intervention — a value
from an earlier step is {"ref": …} and not a bare name — it may
not be a logits view, and its window must be as wide as the write's. A ragged
side has no width until the rows are known, and the compiler checks those row
by row.
One site, several writes #
Writes at one address apply in the order
models.<name>.writes lists them, each one reading
the value the one before it left. Before the first of them, the seam keeps the
address's untouched value; that is f₀, what a pre-write mechanism
measures against, and it is captured once per pass.
renormalize may sit
renormalize is the only mechanism whose operand is not
authored: it is f₀, the feature value at this address before
any write of this forward touched it. So it has to come after at
least one other write at its site and last among them. Listed
first, or alone, f is f₀ and the mechanism is the
identity — a document that orders it that way is refused rather than quietly
doing nothing.
A scaled add, run #
documents/v2/steer_renormalize.json is both rules at once: a
harvested mean is added to the residual stream at four times its own scale,
which also makes the activation longer, and renormalize listed
after it puts the length back. The second model, without the
renormalize, is the comparison.
"writes": {
"steer": {
"site": "target",
"pos": -1,
"mechanism": "add_scaled",
"operand": {
"ref": "direction"
},
"params": {
"scale": 4.0
}
},
"restore": {
"site": "target",
"pos": -1,
"mechanism": "renormalize"
}
},
"models": {
"steered": {
"input": "base",
"writes": [
"steer"
]
},
"steered_renormed": {
"input": "base",
"writes": [
"steer",
"restore"
]
}
},
explain compiles it against the model without loading any
weights, and the write order it prints is the order in the list:
$ uv run causalab-mini explain documents/v2/steer_renormalize.json
<root>: Plan
harvest: Observe metrics=[] outputs=['direction=mean(resid)']
forward 'original' on 'counterfactual' (4 rows x 11 tokens)
read 'resid' at block_output[0] pos={index:-1} via 'identity'
steer: Observe metrics=['logit_diff/logit_diff', 'p_answer/token_prob', 'logit_diff_raw/logit_diff'] outputs=['norm_before=before', 'norm_after=after', 'norm_steered=after_raw'] saves=['logit_diff_renormed.json', 'logit_diff_steered.json']
forward 'original' on 'base' (4 rows x 11 tokens)
read 'before' at block_output[0] pos={index:-1} via 'identity'
forward 'steered' on 'base' (4 rows x 11 tokens)
write 'steer' at block_output[0] pos={index:-1} add_scaled(direction, scale=4.0) via 'identity'
read 'after_raw' at block_output[0] pos={index:-1} via 'identity'
read 'raw_logits' at lm_head pos={index:-1} via 'identity'
forward 'steered_renormed' on 'base' (4 rows x 11 tokens)
write 'steer' at block_output[0] pos={index:-1} add_scaled(direction, scale=4.0) via 'identity'
write 'restore' at block_output[0] pos={index:-1} renormalize() via 'identity'
read 'after' at block_output[0] pos={index:-1} via 'identity'
read 'logits' at lm_head pos={index:-1} via 'identity'
Three forwards, because three named passes are read from. Running it writes the two metric tables:
$ uv run causalab-mini run documents/v2/steer_renormalize.json --device-map cpu --out out/ops-steer
out/ops-steer/document.json
out/ops-steer/run.json
out/ops-steer/logit_diff_renormed.json
out/ops-steer/logit_diff_steered.json
Their four rows, side by side (value from each file, rounded
here):
example_id | steered | steered, renormalized |
|---|---|---|
| 0 | −0.0077 | −0.0283 |
| 1 | +0.1846 | +0.1842 |
| 2 | +0.0096 | +0.0362 |
| 3 | −0.1858 | −0.1925 |
hf-internal-testing/tiny-random-LlamaForCausalLM has random
weights. The two columns differ, which is the mechanism working; neither
column is a fact about a language model. For results that mean something,
see the layer sweep.
A swap, run #
The cheapest write there is: the residual stream at the answer position
replaced by the number 0.0, scored against the un-intervened
model. The operand is a literal, not a read.
documents/v2/zero_ablation.json — the whole document
{
"header": {
"description": "Zero ablation, the cheapest baseline there is: the residual stream at the answer position replaced by 0.0, scored against the un-intervened model. The operand is a number, not a read \u2014 the write seam's operand was never a read-name seam."
},
"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": {
"clean": {
"reads": {
"logits": {
"site": "head",
"pos": -1,
"input": "base"
}
},
"metrics": {
"logit_diff": {
"kind": "logit_diff",
"of": "logits",
"a": "cf_answer",
"b": "base_answer"
}
}
},
"zeroed": {
"reads": {
"logits": {
"site": "head",
"pos": -1,
"model": "zeroed",
"input": "base"
}
},
"writes": {
"zero": {
"site": "target",
"pos": -1,
"mechanism": "swap",
"operand": 0.0
}
},
"models": {
"zeroed": {
"input": "base",
"writes": [
"zero"
]
}
},
"metrics": {
"logit_diff": {
"kind": "logit_diff",
"of": "logits",
"a": "cf_answer",
"b": "base_answer"
},
"p_answer": {
"kind": "token_prob",
"of": "logits",
"token": "cf_answer"
}
}
}
},
"steps": {
"clean": {
"kind": "observe",
"interventions": "clean",
"rows": {
"base": "weekdays/train"
},
"saves": [
{
"value": "logit_diff",
"file_path": "clean.json"
}
]
},
"zeroed": {
"kind": "observe",
"interventions": "zeroed",
"rows": {
"base": "weekdays/train"
},
"saves": [
{
"value": "logit_diff",
"file_path": "zeroed.json"
},
{
"value": "p_answer",
"file_path": "p_answer.json"
}
]
}
}
}
$ uv run causalab-mini explain documents/v2/zero_ablation.json
<root>: Plan
clean: Observe metrics=['logit_diff/logit_diff'] saves=['clean.json']
forward 'original' on 'base' (4 rows x 11 tokens)
read 'logits' at lm_head pos={index:-1} via 'identity'
zeroed: Observe metrics=['logit_diff/logit_diff', 'p_answer/token_prob'] saves=['zeroed.json', 'p_answer.json']
forward 'zeroed' on 'base' (4 rows x 11 tokens)
write 'zero' at block_output[0] pos={index:-1} swap(0.0) via 'identity'
read 'logits' at lm_head pos={index:-1} via 'identity'
Featurizers #
A featurizer is the space a write acts in. Five kinds are declarable, plus
identity, which is never declared and is what a read or write with
no featurizer key gets.
| kind | tensors | k | trainable | err | what a write acts on |
|---|---|---|---|---|---|
subspace | weight, (d, k) | required | yes | always zero | k directions of a Cayley-parametrized rotation |
pca | weight, the (d, k) basis itself | required | no, loaded only | always zero | k fixed orthonormal directions |
gate | weight, (d,) | refused | yes | always zero | the site's units, through a binary mask |
sae | W_enc, W_dec; b_enc, b_dec optional | from the bundle | no, loaded only | x − decode(f) | dictionary latents, after a ReLU |
linear | W_enc; the other three optional | from the bundle | no, loaded only | x − decode(f) | the same, with no nonlinearity |
One featurizer name is one parameter set, so it is used at exactly one
site; a name that appears at two is refused. d is never authored
— the compiler fills it in from the model and the site.
subspace and pca #
A subspace stores a (d, k) parameter, not the
basis. Q is the Cayley transform of the skew matrix that
parameter names, applied to the first k columns of the identity —
Q = (I − A)⁻¹(I + A)E with A = WEᵀ − EWᵀ. That is
orthonormal for every value of the parameter, so a fit is an
ordinary AdamW over an unconstrained tensor: no retraction, no penalty, no
projection after the update, and the rotation cannot drift off the manifold
however long the fit runs. The basis is recomputed on every access, because a
cached one would be stale the moment the optimizer stepped.
An untrained subspace is a reproducible random rank-k
basis drawn from seed, scaled by 1/√d. Unscaled, the
skew matrix's singular values grow like √d and the Cayley
transform saturates toward −I, which at d = 2048 is a
rotation no gradient can move.
A pca is the same featurize/inverse
with Q stored rather than parametrized. It is always loaded from a
file and nothing trains one — it is the control a DAS fit is compared against.
The basis it loads is what reduce: pca writes.
sae and linear: the error term #
These two are the reason err is in the seam at all:
featurize(x) = (f, x − decode(f)) f = act((x − b_dec) W_enc + b_enc)
inverse(f′, err, x) = decode(f′) + err
A write changes what the dictionary explains and leaves what it does not
explain exactly as it was. With f′ = f the activation comes back
to the bit, however bad the reconstruction is. Without the error term every SAE
intervention would also be "replace the activation with its reconstruction",
and the two effects could not be told apart. The tensor names and orientations
are SAELens's — W_enc (d, k), W_dec (k, d) — and a
missing W_dec means tied weights, W_encᵀ.
linear is the same object with the nonlinearity off.
gate: the one piece of mode #
A gate is a learned binary mask over the site's units — the DBM featurizer.
Its features are the activation, which is why it needs no
k:
featurize(x) = (x, 0)
inverse(f, 0, x) = m ⊙ f + (1 − m) ⊙ x
So a swap through a gate takes the masked units from the counterfactual and
leaves the rest alone: the same sentence as a subspace, with "units" for
"directions". One parameter θ has two readings, and which is in
force is the only mode in the library:
| when | mask | why |
|---|---|---|
training = True | m = σ(θ / T) | soft, so a gradient reaches θ |
| otherwise | m = [θ > 0] | hard, so the score is of a real mask |
training is a plain attribute the fit loop sets around an
update and clears around its eval pass. A gate nobody is fitting is always
hard, including one loaded from a file, which is a mask and nothing else.
start puts an unfitted gate at θ = 0: every unit at
σ(0) = ½ while training and — because the hard mask is
θ > 0 — every unit off until a fit says otherwise.
anneal on a fit gives a gate a temperature schedule:
start on the first update, end on the last,
geometric between, so the soft mask the optimizer sees approaches the hard one
the score uses. A gate without a schedule stays at T = 1, and a
fit may only anneal a gate it trains.
In a fit's objective — Σ wᵢ·termᵢ, minimized — a
term is a metric name or <gate>.mask, the mean of the soft
mask, which is the L1 pressure toward keeping few units. Only a gate
this fit trains has such a term. documents/v2/dbm.json
sweeps the weight on it, so that one document is the sparsity curve:
"objective": [
[
1.0,
"ce"
],
[
{
"sweep": [
0.0001,
0.002,
0.01,
0.05
]
},
"mask.mask"
]
],
features: acting on part of the space #
features on a write names the coordinates of the
featurizer's space the mechanism acts on — one SAE latent, three
directions of a rotation. The rest of the feature vector passes through
untouched, and so does whatever the featurizer did not explain. An operand
already in that same space is cut the same way; a literal number, or one
already that narrow, is used as it is.
features is not units. units is part
of the site and narrows the activation before the featurizer sees it
(Locations); features is part of the
write and narrows the feature vector after.
A subspace read, run #
documents/v2/das_apply.json loads the rotation that
das.json fitted and scores it on the held-out split: no fit, no
weights step. Both the read and the write name it, which is what makes
v_cf a vector of eight coordinates rather than an activation.
$ uv run causalab-mini explain documents/v2/das_apply.json
<root>: Plan
featurizers: Featurizers
rot: subspace k=8 d=16 cayley loaded from documents/artifacts/rot.safetensors trained=False
apply: Observe metrics=['iia/logit_diff', 'ce/cross_entropy'] saves=['iia.json', 'ce.json']
forward 'original' on 'counterfactual' (2 rows x 9 tokens)
read 'v_cf' at block_output[0] pos={index:-1} via 'rot'
forward 'patched' on 'base' (2 rows x 9 tokens)
write 'patch' at block_output[0] pos={index:-1} swap(v_cf) via 'rot'
read 'logits' at lm_head pos={index:-1} via 'identity'
$ uv run causalab-mini run documents/v2/das_apply.json --device-map cpu --out out/ops-das
out/ops-das/document.json
out/ops-das/run.json
out/ops-das/iia.json
out/ops-das/ce.json
A loaded bundle's header is checked against the document — model, site,
layer, k, d — and a mismatch is refused naming the
key. The featurizers node in the plan is where the parameters are
built, and it is built where the run runs: a tensor drawn on the
client would not be the tensor the block rotates by.
Metrics #
A metric binds to one read, takes one vocabulary id per row per operand, and returns one value per row. The unit and the estimand version come from the kind and are never authored.
| kind | fields | per row | unit | estimand_version |
|---|---|---|---|---|
match | expected | 1.0 when the argmax is that token, else 0.0 | fraction | match/v1 |
logit_diff | a, b | logit[a] − logit[b] | logit | logit_diff/v1 |
cross_entropy | target | −log softmax(logits)[target] | nat | cross_entropy/v1 |
token_logit | token | the raw logit of that token | logit | token_logit/v1 |
token_prob | token | softmax(logits)[token] | probability | token_prob/v1 |
Every field above is a data column name, not a literal
token: "expected": "cf_answer" means the cf_answer
column of the row, tokenized under token_form (the one form is
space_prefixed). Which fields a kind names, and in which order
compute takes them, is one table — COLUMNS — read
both by the metric classes and by the function that scores them, so a front end
cannot disagree with the engine about which column is which.
of is a read name, and that read must be one position wide. A
metric scores one position per row; a window of several, or a varying one, is
refused at compile time.
Which rows are scored #
Eligibility has two halves, and neither is authoritative alone.
The column half is decided on the client, where the data is: a row is in when every column the metric names has a value. A row whose answer column is null is an excluded measurement, and it is taken out here so the run indexes and never masks — a mean is a mean, and a NaN is still a bug rather than a convention. If no row survives, the compiler refuses: a metric of nothing has no mean.
The position half only the run can know: a text-anchored position may not be found on a given row. The scored set is the intersection, computed per pass, and if it is empty the run raises rather than reporting a number. The reasons a position resolved to nothing are on Positions.
What comes out #
In memory, one value per eligible row, in row order, with no mask — the rectangle's unit window is squeezed off, and a flat read is indexed by each row's place among the ones that resolved.
On disk it is a JSON array with one object per row of the step's data,
including the excluded ones, so a reader can never take an exclusion
for a zero or silently shorten a denominator. One row of
out/ops-lens/layers=0/p_answer.json:
{
"example_id": "0",
"metric": "p_answer",
"value": 2.7850861442857422e-05,
"eligible": true,
"positions": null,
"reason": "",
"unit": "probability",
"estimand_version": "token_prob/v1",
"produced_by": "f0a9953702556caad89e3bd9960a07d36dbd6e89ff7967f50f8d20f3d140377f"
}
When eligible is false, value is
null and reason says which half excluded it.
positions is filled in only by a pass whose positions the document
did not already fix; otherwise there is nothing to report that the document
does not already say. produced_by is the digest of the experiment:
everything in the document except header.
A metric, run #
documents/v2/logit_lens.json is two metrics on one
view: "logits" read, swept over two layers.
documents/v2/logit_lens.json — the whole document
{
"header": {
"description": "The logit lens as one document: at every layer, push the residual stream at the answer position through the model's final norm and head and read what it would say if that were its last layer. `view: logits` is the projection; the layer sweep is the lens; `token_prob` on the answer is what to plot against depth. Two layers here, so two points."
},
"model": {
"key": "hf-internal-testing/tiny-random-LlamaForCausalLM",
"revision": "9fb191250dd56d0ba7ec9785a025ed29c03d5998",
"dtype": "fp32"
},
"roles": {
"base": {
"field": "input"
}
},
"sites": {
"resid": {
"component": "block_output",
"layers": {
"sweep": [
[
0
],
[
1
]
]
}
}
},
"interventions": {
"lens": {
"reads": {
"lens": {
"site": "resid",
"pos": -1,
"input": "base",
"view": "logits"
}
},
"metrics": {
"p_answer": {
"kind": "token_prob",
"of": "lens",
"token": "base_answer"
},
"top1": {
"kind": "match",
"of": "lens",
"expected": "base_answer"
}
}
}
},
"steps": {
"lens": {
"kind": "observe",
"interventions": "lens",
"rows": {
"base": "weekdays/train"
},
"saves": [
{
"value": "p_answer",
"file_path": "p_answer.json"
},
{
"value": "top1",
"file_path": "top1.json"
}
]
}
}
}
$ uv run causalab-mini explain documents/v2/logit_lens.json
<root>: Plan
layers=0: Plan
lens: Observe metrics=['p_answer/token_prob', 'top1/match'] saves=['p_answer.json', 'top1.json']
forward 'original' on 'base' (4 rows x 11 tokens)
read 'lens' at block_output[0] pos={index:-1} via 'identity' as logits
layers=1: Plan
lens: Observe metrics=['p_answer/token_prob', 'top1/match'] saves=['p_answer.json', 'top1.json']
forward 'original' on 'base' (4 rows x 11 tokens)
read 'lens' at block_output[1] pos={index:-1} via 'identity' as logits
$ uv run causalab-mini run documents/v2/logit_lens.json --device-map cpu --out out/ops-lens
out/ops-lens/document.json
out/ops-lens/run.json
out/ops-lens/layers=0/document.json
out/ops-lens/layers=0/p_answer.json
out/ops-lens/layers=0/top1.json
out/ops-lens/layers=1/document.json
out/ops-lens/layers=1/p_answer.json
out/ops-lens/layers=1/top1.json
outputs and reduce #
An observe step may publish a read for the steps after it.
Each entry names a read and how to reduce it; a bare read name is shorthand for
keeping it whole.
reduce | what it does | shape out | a document |
|---|---|---|---|
none | Keeps the read as it is, rows and all. | as gathered | documents/v2/sae_feature_ablation.json |
mean | tensor.mean(dim=0). | one axis fewer | documents/v2/mean_ablation.json |
pca | The top-k principal directions of the read, orthonormal by construction. | (d, k) | documents/v2/pca_harvest.json |
k is for pca and pca needs it;
either without the other is refused.
mean averages over
mean(dim=0) is axis zero of the gathered value, and which
axis that is depends on how the read came back. A rectangular read is
(rows, w, width), so the mean is over rows and
the window survives: a two-position read averaged this way is still two
positions wide. A ragged read is flat, (total, width), with no
window axis to keep, so the mean is over every (row, position) pair
at once.
pca reshapes to (-1, d) first, so it is always
over every vector the read produced, centered, and takes the right singular
vectors. documents/v2/pca_harvest.json harvests every token of
four prompts — about 36 vectors — for a rank-4 basis, and says why: four
vectors have no rank-4 PCA, and the compiler says so too.
"outputs": {
"basis": {
"read": "acts",
"reduce": "pca",
"k": 4
}
},
A published value lives in the walk's state, not on disk, unless a
save asks for it. A later step reaches it with
{"ref": …}. An unreduced output also carries the positions it was
read over, so that when a later step runs in batches the operand is sliced the
same way the rows are and a write still meets the operand of its row.
A mean has no row axis, and every window of rows shares it whole.
Three rules on the names: an output must be published before it is referenced, two steps may not publish the same name, and an output may not share its name with a read — an operand names one or the other.
This is how a corpus mean gets made without the un-reduced activations ever
leaving the run. documents/v2/mean_ablation.json is three steps:
harvest and publish, score the clean model, swap the published mean in and
score again.
"outputs": {
"mean": {
"read": "acts",
"reduce": "mean"
}
},
Sweeps #
{"sweep": [a, b, c]} at any field means the
document is three points — the same experiment three times, differing
in that one field. A sweep is lowered on the client, on the raw JSON, before
anything is compiled: the wrapper is replaced by each of its values in turn and
each resulting document is compiled on its own. A point is an ordinary document
in every way, with its own addresses, its own tokenization and its own digest,
and nothing downstream of the compiler knows a sweep happened.
A wrapper is any object whose keys are a subset of
{"sweep", "as"}, found anywhere in the document, in reading order.
Both front ends refuse a document that still has one: a document and a plan are
each one point.
The two spellings beyond a list #
{"sweep": {"range": [0, 16]}} is 0, 1, … 15:
[start, stop] or [start, stop, step], in integers, as
Python's. documents/real/weekdays_layer_sweep.json uses it for the
layer:
"component": "block_output",
"layers": [
{
"sweep": {
"range": [
0,
16
]
}
}
]
"as" names an axis, and every wrapper with the
same name moves together as one coordinate. The cross product is sometimes the
wrong thing: a patch reads at a position and writes at the same one, and swept
separately those are N² points, most of them reading one place and writing
another. The same document names its position axis twice, once on the read and
once on the write:
"pos": {
"sweep": [
-4,
-1
],
"as": "pos"
},
Three wrappers, two axes: 16 layers × 2 positions is 32 points, not 64. Fields that move together need a value each per point, so two wrappers sharing a name and disagreeing on how many values they have is refused, naming both paths. The layer sweep runs this document end to end.
How points are laid out #
Several independent wrappers are their cross product, one point per
combination, in the order the fields appear.
documents/real/weekdays_head_patching.json sweeps a layer and a
head separately: 3 × 32 = 96 points, labelled
layers=10,heads=0 through layers=12,heads=31.
A point's label is the swept field's own name and the value it took. The
name is the axis's, when it has one, else the last string key on the path to
the wrapper — which is why a weight swept at
steps.fit.objective.1.0 is labelled objective=…. A
one-element list sweeps as its element, so [[0], [1]] gives
features=0 and not features=[0]; anything else is
compact JSON.
The label is the point's name in the plan tree and its directory on disk.
Running documents/v2/neuron_patching.json, which sweeps which MLP
neurons the interchange covers:
"units": {
"sweep": [
[
0,
1,
2,
3
],
…
]
}
$ uv run causalab-mini run documents/v2/neuron_patching.json --device-map cpu --out out/ops-neuron
out/ops-neuron/document.json
out/ops-neuron/run.json
out/ops-neuron/units=[0,1,2,3]/document.json
out/ops-neuron/units=[0,1,2,3]/iia.json
out/ops-neuron/units=[0,1,2,3]/logit_diff.json
out/ops-neuron/units=[4,5,6,7]/document.json
out/ops-neuron/units=[4,5,6,7]/iia.json
out/ops-neuron/units=[4,5,6,7]/logit_diff.json
out/ops-neuron/units=[8,9,10,11]/document.json
out/ops-neuron/units=[8,9,10,11]/iia.json
out/ops-neuron/units=[8,9,10,11]/logit_diff.json
Each point gets its own document.json, which is that point's
document with the wrapper replaced — the thing that was actually compiled. A
nested plan gets its own published outputs too, so points cannot see each
other's.
Sweeping features on a write gives a feature-at-a-time
ablation, which is what documents/v2/sae_feature_ablation.json
is. Its four points, trimmed to two:
$ uv run causalab-mini explain documents/v2/sae_feature_ablation.json
<root>: Plan
features=0: Plan
featurizers: Featurizers
dictionary: sae k=32 d=16 loaded from documents/artifacts/sae.safetensors trained=False
ablate: Observe metrics=['logit_diff/logit_diff', 'p_answer/token_prob'] outputs=['active=latents'] saves=['zeroed.json', 'p_answer.json']
forward 'original' on 'base' (4 rows x 11 tokens)
read 'latents' at block_output[0] pos={index:-1} via 'dictionary'
forward 'zeroed' on 'base' (4 rows x 11 tokens)
write 'ablate' at block_output[0] pos={index:-1} swap(0.0) via 'dictionary' on its features [0]
read 'logits' at lm_head pos={index:-1} via 'identity'
features=1: Plan
featurizers: Featurizers
dictionary: sae k=32 d=16 loaded from documents/artifacts/sae.safetensors trained=False
ablate: Observe metrics=['logit_diff/logit_diff', 'p_answer/token_prob'] outputs=['active=latents'] saves=['zeroed.json', 'p_answer.json']
forward 'original' on 'base' (4 rows x 11 tokens)
read 'latents' at block_output[0] pos={index:-1} via 'dictionary'
forward 'zeroed' on 'base' (4 rows x 11 tokens)
write 'ablate' at block_output[0] pos={index:-1} swap(0.0) via 'dictionary' on its features [1]
read 'logits' at lm_head pos={index:-1} via 'identity'
…
What is refused #
All of it at compile time, before a weight is loaded, and every message names the path it was at.
| the document says | why not |
|---|---|
A mechanism without its required params, or with ones it does not take | Each mechanism names the numbers it takes; clamp needs at least one bound. |
An operand on gaussian, clamp or renormalize, or none on the other three | One draws its own noise, one only bounds, one measures against the value the seam supplies. |
renormalize first at its site, or alone there | It would be the identity. See above. |
| A metric on a read of more than one position | A metric scores one position per row. |
| A write whose window is wider or narrower than its operand's read | The windows have to match. A ragged side is checked row by row at compile time. |
view: "logits" off the residual stream, or on a featurized read | The final norm and head take a residual stream, not a feature vector. |
| A logits read used as a write's operand | It is vocabulary-wide and cannot be written back at a site. |
A write at input_ids | Read-only: the model's input, not an activation. |
| One featurizer name used at two sites | One name is one parameter set, so it is one site. |
A gate with a k or a seed; a subspace or pca without k; a pca, sae or linear without file_path; a loaded featurizer with a seed | A gate masks every unit and starts at θ = 0; a basis needs a rank; the loaded-only kinds exist as a file; loaded weights are their bytes. |
A fit that trains a pca, sae or linear | Fixed by definition. A pca basis can be fine-tuned as a subspace loaded from it. |
| A step that uses a featurizer before the fit that trains it | It would score an untrained rotation without complaint. A deliberate untrained baseline is a second featurizer that no fit names. |
An objective term or an anneal entry that is not a metric or a gate this fit trains | Only a gate being fitted has a mask term and a temperature. |
A sweep on model or header | The engine loads the model once and every point runs against it; those are the identity of the run, not a coordinate in it. |
Two wrappers sharing an "as" with different numbers of values | Fields that move together need a value each per point. |
An empty sweep, or a range that is not two or three integers with a non-zero step | A sweep of nothing has no points. |
See also #
- Locations — what a
sitenames, and howheadsandunitsset the width a featurizer sees - Positions — the
posvocabulary, the continuation frame, and the reasons a row resolves to no window - Documents — the rest of the document:
roles,steps,saves, and the JSON Schema - Layer sweep — a swap, a named sweep axis and two metrics on a real model, with the result
- DAS fit — fitting a
subspace, and the controls to compare it against - DBM fit — the
gate, its anneal and the sparsity curve - Sweeps — more swept documents, and what the points cost