causalab-mini

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.

fielddefaultwhat 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.

two refusals

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.

The write seam Five steps in a column — gather, featurize, do, inverse, scatter. Two side paths rejoin at inverse: the error term that featurize set aside, and the pre-write activation x itself. gather featurize do inverse scatter the site, at the positions x → (f, err) the mechanism, on its operand (f′, err, x) → x′ x′ back into the tensor x f f′ x′ err x, pre-write
Why 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.

featurizer math runs outside autocast

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.

mechanismon the feature value foperandparams
swapoperandrequirednone
add_scaledf + scale·operandrequiredscale (default 1.0)
lerp(1−t)·f + t·operandrequiredt, required
gaussianf + scale·ε, ε ~ N(0,1)noneseed required, scale optional
clampmin(max(f, lo), hi)nonelo, hi, at least one
renormalizef · ‖f₀‖ / ‖f‖supplied by the seamnone

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:

spellingwhat it isa 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.0A literal number. Zero ablation is this.documents/v2/zero_ablation.json
absentFor 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.

where 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_idsteeredsteered, renormalized
0−0.0077−0.0283
1+0.1846+0.1842
2+0.0096+0.0362
3−0.1858−0.1925
this checkpoint knows nothing

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.

kindtensorsktrainableerrwhat a write acts on
subspaceweight, (d, k)requiredyesalways zerok directions of a Cayley-parametrized rotation
pcaweight, the (d, k) basis itselfrequiredno, loaded onlyalways zerok fixed orthonormal directions
gateweight, (d,)refusedyesalways zerothe site's units, through a binary mask
saeW_enc, W_dec; b_enc, b_dec optionalfrom the bundleno, loaded onlyx − decode(f)dictionary latents, after a ReLU
linearW_enc; the other three optionalfrom the bundleno, loaded onlyx − 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:

whenmaskwhy
training = Truem = σ(θ / T)soft, so a gradient reaches θ
otherwisem = [θ > 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.

kindfieldsper rowunitestimand_version
matchexpected1.0 when the argmax is that token, else 0.0fractionmatch/v1
logit_diffa, blogit[a] − logit[b]logitlogit_diff/v1
cross_entropytarget−log softmax(logits)[target]natcross_entropy/v1
token_logittokenthe raw logit of that tokenlogittoken_logit/v1
token_probtokensoftmax(logits)[token]probabilitytoken_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.

reducewhat it doesshape outa document
noneKeeps the read as it is, rows and all.as gathereddocuments/v2/sae_feature_ablation.json
meantensor.mean(dim=0).one axis fewerdocuments/v2/mean_ablation.json
pcaThe 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.

what 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 sayswhy not
A mechanism without its required params, or with ones it does not takeEach mechanism names the numbers it takes; clamp needs at least one bound.
An operand on gaussian, clamp or renormalize, or none on the other threeOne draws its own noise, one only bounds, one measures against the value the seam supplies.
renormalize first at its site, or alone thereIt would be the identity. See above.
A metric on a read of more than one positionA metric scores one position per row.
A write whose window is wider or narrower than its operand's readThe 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 readThe final norm and head take a residual stream, not a feature vector.
A logits read used as a write's operandIt is vocabulary-wide and cannot be written back at a site.
A write at input_idsRead-only: the model's input, not an activation.
One featurizer name used at two sitesOne 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 seedA 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 linearFixed 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 itIt 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 trainsOnly a gate being fitted has a mask term and a temperature.
A sweep on model or headerThe 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 valuesFields 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 stepA sweep of nothing has no points.

See also #

  • Locations — what a site names, and how heads and units set the width a featurizer sees
  • Positions — the pos vocabulary, 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