causalab-mini

Reference

The engine

How the engine turns a plan into tensors.

Two halves, and the seam between them is the design. The compiler takes a document and decides everything that needs a model but no activations — an address per site, a width per address, the token ids of every prompt, the order the forwards must run in — and returns a Plan: strings, integers and tuples. An engine walks that plan. What each step means is written once, in engine/steps.py, as plain functions taking an engine; an engine supplies eight members and nothing else.

Results come home by filling the plan in. A step's results is the only mutable thing on it, so every number sits on the node that produced it, and a node's path in the tree is its path on disk.

At a glance #

explain compiles a document against the model's config, with no weights loaded, and prints the plan it produced:

$ uv run causalab-mini explain documents/v2/patching.json
<root>: Plan
  score: Observe  metrics=['iia/match', 'logit_diff/logit_diff']  saves=['iia.json', 'logit_diff.json']
      forward 'original' on 'counterfactual'  (4 rows x 11 tokens)
        read  'v_cf' at block_output[0] pos={index:-1} via 'identity'
      forward 'patched' on 'base'  (4 rows x 11 tokens)
        write 'patch' at block_output[0] pos={index:-1} swap(v_cf) via 'identity'
        read  'logits' at lm_head pos={index:-1} via 'identity'

That tree is the plan. Everything below is what put it there and what walks it.

The pipeline #

A document, compiled and executed Five boxes down the page in the order things happen: document.json, Plan, steps.run, engine.forward, and the output directory. A curved arrow runs from engine.forward back up into the Plan, because the results of a forward are set on the plan's own steps rather than returned. document.json Plan steps.run engine.forward out/ roles · sites · steps strings, ints, tuples the walk: observe · fit a trace, or hooks iia.json · run.json compile execute one forward plan.write(out) results fill the plan
Top to bottom is the order things happen, not where data goes: a forward returns nothing. It leaves what it read in a dict, and the walk sets the metrics over that on the step that asked for them — which is why the curved arrow points back into the same Plan the compiler produced, and why that plan is what writes itself to disk.

One document, all the way through #

The document is the smallest real experiment in the repository: read the residual stream at the last token of a counterfactual prompt, swap it into the same place on the base prompt, score what the model then says. The home page shows it in full; what follows is what happens to it.

1. Compile #

build_spec works in the order of the file. Each site's layer is checked against engine.num_layers, then each site becomes an Address through engine.locate; each site that names units or heads gets its count from engine.width or engine.heads, and an index past the end is refused here rather than on a tensor. A featurizer's d is derived from (model, site), because a traced block may not decide anything from a tensor — including how wide the tensor it is about to rotate is.

Then the forwards. A read in model A may be the operand of a write in model B, so B runs after A; _schedule topologically sorts the (model, input) pairs and refuses a cycle. Here it gives original on counterfactual, then patched on base — the order explain printed.

Within a forward, taps are sorted into forward order by Address.key, and at one address the writes go before the reads, because a read in a model sees that model's writes.

Three checks belong to the compiler because they are decidable without a run:

  • An attention pattern swapped in from another prompt must line up key for key — the last axis of attention_probs is the keys of the padded batch, so the two prompts must pad to the same width and have the same number of real tokens row by row. Both masks are in hand before any forward.
  • A read that buffers every decode step has a size bound — decode × rows × width × 4 bytes, refused over 256 MiB. All three numbers are known before a model is loaded, so the line is drawn before the memory is held.
  • A write's features must be distinct indices inside the featurizer's space.

2. The plan #

A Plan is a dict of named steps, and is itself a step, so plans nest — a sweep point is a nested plan. Four kinds of step sit in one: Featurizers constructs the parameter sets, Observe is one pass (forwards in order, then metrics over what they read), Fit is that pass N times with an optimizer between, and Weights turns fitted parameters into results. Inside an Observe, a Forward holds its token ids and a tuple of Taps, one per address, each with its writes and reads.

Every field is frozen except results. There is no execute on a step: what running one means is the engine's business, and a plan that knew would only work on one engine.

Two things cross between steps. An Observe's outputs publish a read — kept, or reduced with mean or pca — under a name a later step's write can name as its operand via ref. And the featurizers are shared live, which is what lets a fit train the rotation a later step scores with. Both live in the walk's State, which is a local of the walk: never saved, never shipped, gone with the plan. A nested plan gets its own outputs and the same featurizers, so sweep points cannot see each other's values.

3. The walk #

steps.run(engine, step) is the whole executor: a Plan recurses into its children, and each other kind of step goes to its own function. Every one of those functions takes the engine as its first argument and calls it for exactly one thing — running a forward. None of them touches the model.

observe runs the pass, then computes the metrics. A metric's eligible rows are the intersection of two halves: the column half, which the client decided from the data, and the position half, which only the run can know. The scored metrics are returned live, because a fit differentiates them, and recorded detached, because what comes home should not carry a graph.

4. The per-forward frame #

Positions are resolved here — inside the session, in the walk, against engine.tokenizer — and not on the client, so that a text anchor is looked for in the text the model will actually see. One Frame is built per forward and every tap of that forward shares it.

The frame's character map is the expensive half: O(L) decode calls of O(L) work per row, measured at 840 ms for 64 rows of 260 tokens. So it is built only when the pass has a position the document does not already fix — a text anchor, or any cut of the continuation. A pass of bare -1s resolves the same integers on every row and every run, and skips the map.

Before looking for any text in a row, the run decodes row 0's ids with its own tokenizer and compares the string to the one the client recorded when it encoded them. Locally those are one object; remotely they are two, and a disagreement would put every position in range and in the wrong place, silently, because both sides produce integers. One row is a sample, not a proof.

What resolution returns beside the forward is the run's own record: per op, each row's window, the reason it is empty when it is, and the tokens it actually addressed. That is plain tuples of integers and strings, so it comes home in results like a metric does.

a read may skip a row; a write may not

A read whose anchor is not in a row's prompt is an excluded measurement. A write there is refused: writing nothing somewhere is not an intervention, and the row would score as if one had happened. Both are checked at the write, because that is where the positions are, and the row numbers are put back into the pass's own terms so a batched run names the row an author would count to.

5. One forward #

engine.forward(forward, values, featurizers) is the only place an engine touches a model's insides. It returns nothing: it leaves what it read in values, keyed by the read's name. values starts as a copy of the published outputs, so a write's operand may be either a read of this pass or something an earlier step published — and the engine cannot tell the difference and does not need to.

6. Home #

results_of flattens a run into {step path: {name: tensor}} — plain strings and tensors, with none of this package's classes in it — and fill is its inverse. That pair is what crosses back from a server, and it is also what makes the returned plan the same object that was passed in.

Plan.write then writes the save manifest and nothing else: nothing lands on disk that is not listed in a step's saves. A nested plan writes into a directory named by its step name, so a swept point's files land under pos=-1/. On the root, the directory also gets document.json (the experiment, verbatim) and run.json.

$ uv run causalab-mini run documents/v2/patching.json --device-map cpu --out out
out/document.json
out/run.json
out/iia.json
out/logit_diff.json

run.json is the engine, whether it ran remotely, the batch size, the versions of the five packages whose code decides what a block does, a sha256 over this package's own source, and the dtype the model was actually served in. No hostname, no timestamp, no absolute path — a record has to be byte-identical across machines to be worth comparing.

{
 "engine": "NNterpEngine",
 "remote": false,
 "batch_size": null,
 "versions": {
  "torch": "2.14.0",
  "transformers": "5.17.0",
  "nnsight": "0.8.1.dev125+ga8ee93782",
  "nnterp": "1.3.1.dev64+g0e4b401af",
  "pydantic": "2.13.5"
 },
 "causalab_mini": "76c8c9d0c0d1f3e0b8e2eaeb09564107d1c0a70cb9e8b7d6cecab09fdc92d8a1",
 "served_dtype": "torch.float32"
}

The engine contract #

Nine names: eight members, and model, which is an attribute. Nothing in base.py has a body, so an engine inherits no behaviour it did not ask for.

memberasked bywhat it answers
model— The loaded model, as an attribute. What kind of object it is, is the engine's business.
load(spec, **options)loading A classmethod: the engine, holding the model the document named. spec is a model block from either authoring format; options are the runtime's own.
tokenizercompile, run Resolves prompts and answer columns on the client, and — in the run — where along them a position lands.
num_layerscompile Bounds the layer band a site may name.
locate(component, layer)compile The Address of a place on this model. A module boundary is just the pair; an interior also needs the operation inside the forward resolved, and how that is done is the runtime's business.
heads(address)compile How many heads the per-head tensor there has.
width(address)compile The size of the tap's last axis — the d a featurizer's k is a subspace of. Derived from (model, site), never authored.
execute(plan, remote, batch_size)run Run the plan and return it, filled in. The plan that comes back is the one passed in.
forward(forward, values, featurizers)run One forward with its taps applied, leaving what it read in values.

The walk over steps, the fit loop, the metrics and the write algebra are shared, so a second engine is these eight members and no more. A test asserts the set of public names each engine class overrides, on both engines, so a member added quietly fails the suite.

What both engines share below forward #

ops/intervene.py knows nothing about layers, models or documents: positions arrive as integers and activations arrive as tensors. One line is the seam the rest of the project is built around:

write = inverse(do(featurize(x)), err, x)

With the identity featurizer that collapses to "replace the tensor", which is activation patching. Swap in a rotation and the same line is DAS — the error term and the unselected directions come from x, the pre-write value, which is what makes a subspace swap leave the orthogonal complement alone.

gather takes a window per row and, of it, the features the selection names. Uniform windows keep the rectangle, (batch, seq, width) → (batch, w, width); ragged windows cannot and come back flat, (total, width), with the plan's own positions saying where each row begins and ends. Which shape it is is decided by the positions, never by the tensor — and, for a batched run, decided once by the compiler over every row, because a window of a ragged pass's rows can happen to be rectangular. scatter is the inverse, and it clones.

Featurizer arithmetic runs inside intervene.exact, which disables autocast. A server may wrap a whole request in torch.autocast — NDIF does, at the served dtype — and inside one a matmul of two fp32 tensors comes back bf16 while a subtraction stays fp32, so half of a Cayley solve is downcast and linalg.solve refuses the pair. A featurizer declares its own dtype, fp32, and this is where that declaration is kept.

One session #

The nnterp engine's whole request is one model.session(...): not a session per forward, not a session per training step, not a loop outside that opens sessions.

causalab_mini/engine/engines/nnterp/engine.py — the run half, in full
    def execute(self, plan: Plan, remote: bool | str = False, batch_size: int | None = None) -> Plan:
        plan.provenance.update(provenance.record(self, remote, batch_size))
        engine, model = self, self.model
        with model.session(remote=remote):
            # What comes home is plain — strings and tensors. The client
            # already has the plan, so only what fills it in needs the trip,
            # and nothing of ours has to survive the way back. (`remote="local"`
            # never noticed: it does not serialize the way back. FINDINGS §19.)
            home = nnsight.save({})
            steps.run(engine, plan, batch_size=batch_size)
            home.update(plan_module.results_of(plan))
            # A server serves the dtype *it* chose; the document's is only a
            # request. Say what ran, where the run record is.
            home["served_dtype"] = str(model.lm_head.weight.dtype)
        plan.provenance["served_dtype"] = home.pop("served_dtype")
        plan_module.fill(plan, home)
        return plan

remote is the only difference between running here and running on NDIF. There is no second code path: the flag is passed to that one session. What the command line changes besides the flag is how the model was loaded — --engine ndif is this same engine with dispatch=False, a shell with no local weights, because the weights are the server's.

Results come home because the session saves a dict. nnsight.save({}) hands back an object the block fills; locally that is the dict itself, remotely it is the copy that will be shipped home. What goes into it is results_of(plan) — plain strings and tensors by step path. The plan itself does not come back: the client never gave it up, so only what fills it in needs the trip. Nothing of ours ships by value either way — the block's module references and the plan's classes resolve by import on the server, which is why it needs causalab_mini and nnterp installed at the client's versions (FINDINGS §24).

what a traced block may name

nnsight recompiles a traced block from its source and ships every name the block loads as a whole pickled object. So everything inside a block here is a module-level function taking plan data and tensors: no document, no dataset, no bare tokenizer, no self. tests/test_structure.py parses the package's own source and checks the ast.Name nodes of every with ….trace( and .session( block against that rule. An attribute is not a name, which is why the run reaches a tokenizer as model.tokenizer — one of nnsight's persistent objects, written as an id and resolved on a server to the served checkpoint's own.

Two things happen at load. nnsight loads weights lazily, on the first trace, by replacing the module — so the loader forces dispatch=True and freezes afterwards, and what is frozen is what runs. The freeze is eval() and requires_grad_(False): the model is an instrument, not a parameter. Without it a fit's backward reaches every weight the patched forward touched and leaves a .grad the size of the model behind. Nothing reads it; the memory is real.

A forward with no decode is one model.trace(...). A forward that generates is one model.generate(...) with tracer.iter walking the steps: prompt-frame taps apply at step 0, the prefill; a step's own taps at that step; an "all" write at every step. Greedy, with EOS held off so the bound holds and the loop never outruns the run.

An interior — one argument of one call inside a module's forward — is resolved at locate time, on the client, against the loaded checkpoint's .source. The match is on the source line rather than the operation's name: nnsight names an operation {callable}_{occurrence} and gives assignments the same namespace as calls, so on transformers 5.17 the attention forward has both an attention_interface_0 (the assignment) and an attention_interface_1 (the call). The needle "attention_interface(" is call-shaped and hits one. A document that cannot be addressed fails at compile time, not inside someone else's process.

The reference engine #

The second engine is a plain AutoModelForCausalLM and register_forward_hook: no session, no envoys, no tracing, no remote. One model(**batch) per forward, with a hook on each module a tap names, doing the reading and the writing on the way out. It exists to test one claim — that everything else is genuinely engine-independent — and it shares engine/steps.py and ops/ unchanged.

What it costs to not be standardized is one function: loading.standardized builds an object carrying nnterp's spellings, so Address.resolve walks it unchanged. Three of the six names are published by transformers and needed no family knowledge at all — the decoder under base_model_prefix, the head under get_output_embeddings, the embeddings under get_input_embeddings. The other three are conventions the engine guesses at, and each guess raises rather than picking a first match. See Standardization for what nnterp is doing there.

What it refuses, by name:

  • Every interior. A hook fires at a module boundary; attention_query never crosses one. Reaching it would mean either a patched forward per family or reimplementing, inside the engine, the head reshape and the rotary embedding the address says have already happened. Both move per-family model code back into the engine, which is the cost this project exists to measure.
  • remote, in any form, including "local". A hook is a Python callable registered on a module object in this process, and there is nothing to ship. Running here silently would produce correct numbers under a false claim about where they came from.
  • Running from a meta shell. nnsight's shell can run remotely; a hooks shell has nowhere to run, so the engine — not the base contract — says so.

Where a tensor sits inside a module's value is nnterp's row to say, as a Selection with get and put, and the hooks engine asks the row rather than deciding once. Unwrapping a tuple's first element is one of those (FirstIfTuple); the model's own output, whose logits field is the tensor, is another (Index("logits")). So logits — the component that on a capping family is not lm_head — reads the same bits on both engines, and the hooks engine sees Gemma-2's cap because it is reading the model's output rather than reproducing it.

One thing is harder here than under tracing. The nnterp engine's taps live and die with the with model.trace(...) block; hooks outlive the forward unless removed, and a leaked handle intervenes on the next forward — a wrong number, not an error, and the next forward is usually the one being scored. The registration loop is wrapped in try/finally, and a test asserts the module's _forward_hooks count is unchanged afterwards.

Batching windows #

batch_size bounds how many rows one model call holds. It is a property of the run and not of the experiment — it bounds memory and moves the last bit, nothing else — so it arrives with execute and is absent from the plan.

It lives in the shared walk, in one function. A window of rows is a whole small pass: the same forwards in the same order over a slice of the rows, with the published values it reads sliced the same way, so a write still meets the operand of its row. Every per-row thing a forward holds is a tuple with one entry per row — ids, mask, each op's anchors and positions — so a window is a slice of each, and positions are absolute indices into the padded width the client fixed once for all rows, so they survive the slice unchanged. An engine is handed a forward that is merely shorter and cannot tell. What the windows read is concatenated back in row order, and everything after that — metrics, eligibility, outputs, saves, a fit's loss — sees one pass.

Neither engine changed to get this, and remote="local" windows identically, because the windowing ships with the walk.

What it does change is the last bit: a GEMM over fewer rows rounds differently. The same document, run whole and at --batch-size 1:

rowevery row at once--batch-size 1
0−0.027417995035648346−0.027418017387390137
10.180859923362731930.18085995316505432
20.035145036876201630.03514504432678223
3−0.19330616295337677−0.19330619275569916

About two parts in 108, which is why run.json records the batch size beside the versions.

It does not free memory inside a fit's update: the loss is the mean over the concatenated rows, so the graph of every window is alive until the backward. A fit's memory knob is pairs. Windowing does bound the held-out pass, which runs under no_grad.

A fit is the same plan, repeatedly #

Nothing about the training loop is a second engine. A Fit holds the rows of every update it will make, already batched, already tokenized, already in the order the seed puts them in — as Observe steps, because a training update is the same pass over different rows. An update is observe over one minibatch, and the only things between updates are an optimizer step and an eval pass.

$ uv run causalab-mini explain documents/v2/das.json
<root>: Plan
  featurizers: Featurizers
      rot: subspace k=8 d=16 cayley seed=0 trained=True
  fit: Fit  10 epochs x 1 update  lr=0.001 objective=((1.0, 'ce'),) params=('rot',)  early_stop='iia' patience=3
      epochs[0][0]: Observe  metrics=['iia/logit_diff', 'ce/cross_entropy']
          forward 'original' on 'counterfactual'  (2 rows x 11 tokens)
            read  'v_cf' at block_output[0] pos={index:-1} via 'rot'
          forward 'patched' on 'base'  (2 rows x 11 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'
      evaluation: Observe  metrics=['iia/logit_diff', 'ce/cross_entropy']  saves=['held_out_iia.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'
  score: Observe  metrics=['iia/logit_diff', 'ce/cross_entropy']  saves=['iia.json', 'ce.json']
      …
  weights: Weights  names=['rot']  saves=['rot.safetensors']

epochs[0][0], evaluation and score are the same pass over different rows. The Featurizers step above them constructs the parameter sets where the run runs, and that is the whole point: the parameter it draws is the tensor the optimizer will step and the write will use. Drawn on the client it would be a different tensor from the one the block rotates by.

The optimizer is created inside the session, over [featurizers[name].weight for name in step.params]. step.params is the protocol's only trainability declaration, so the optimizer's parameter list is it — the model is frozen and nothing else in the run carries a gradient.

Per epoch: the featurizers go into training mode, each update scores the pass, builds the objective, and steps; then they go into eval mode and the held-out pass runs under no_grad on rows the fit never saw. Early stopping watches one named metric with a mode and a patience. A gate's temperature is annealed geometrically from the first update to the last.

The objective is Σ wᵢ·termᵢ, minimized. The sign of the weight is the direction — a positive weight on a cross-entropy minimizes it, a −1 on a logit difference maximizes the margin — and there is no maximize flag anywhere.

Every Observe records what it scored, including the ones inside a fit, so a run is navigable at any depth: root.steps["fit"].epochs[0][1].results["iia"]. On a long fit that is a real amount of small tensors coming home.

What the tests pin #

486 tests, and these are the ones about the seam.

claimtest
An engine's public surface is exactly those eight names — asserted separately on both engines test_engine.py:83, test_hooks_engine.py:182
Engine has no implementation: every member raises test_engine.py:60
A plan compiled for nnsight runs unchanged on an engine with no model and no session at all test_engine.py:119
An engine that ships holds nothing but its model test_engine.py:100
A weightless shell compiles the same plan as a model with weights, on both engines test_engine.py:140
The two engines answer the compiler identically — layer count, padding side, addresses, widths test_hooks_engine.py:53
The two engines produce the same numbers on documents/minimal_cpu.json, by torch.equal test_hooks_engine.py:65
…and the same fitted (16, 8) rotation through ten AdamW updates, not just the metrics over it test_hooks_engine.py:82
No batch_size is one window and changes nothing, by torch.equal test_batching.py:109
A windowed run equals the whole run across seven kinds of pass at batch sizes 1 and 3 test_batching.py:100
Windowing ships with the walk: remote="local" windows identically test_batching.py:193
No trace body names a document, a dataset, a bare tokenizer or self test_structure.py:132, :147
A forward leaves no hook behind test_hooks_engine.py:134

The cross-engine result reproduces from the command line. The same document through both engines gives logit_diff equal in every decimal place — and these are the four numbers FINDINGS.md records from a different machine:

$ uv run causalab-mini run documents/v2/patching.json --engine nnterp --device-map cpu --out out/nnterp
$ uv run causalab-mini run documents/v2/patching.json --engine hooks  --device-map cpu --out out/hooks
$ jq -c '[.[].value]' out/nnterp/logit_diff.json out/hooks/logit_diff.json
[-0.027417995035648346,0.18085992336273193,0.03514503687620163,-0.19330616295337677]
[-0.027417995035648346,0.18085992336273193,0.03514503687620163,-0.19330616295337677]
bit-parity is a property of the documents that were run

It is not a property of writing. Recorded measurements: a single-position swap is exact; replacing three positions in one write differs across the two engines by 1.49e-08 on one of four rows, and so does installing three writes at three positions; a single-position write of a harvested mean differs by 7.45e-09 on one row while a zero ablation at the same position is exact. The divergence is input-dependent, enters after a replacement is installed, and is on the order of one ulp when it appears. It is also machine-dependent — on one CPU the engines differ where on another they do not — which is why nine cross-engine assertions state a tolerance and carry the measurement in their docstring.

See also #

  • Standardization — what nnterp names for the nnterp engine, and what the hooks engine had to guess to reach the same modules without it
  • Remote & NDIF — the same session with remote=True: what ships, what comes back, and what a served checkpoint decides for you
  • The CLI — --engine, --batch-size and what lands in the output directory
  • Layer sweep — the same machinery producing a result on a real model
  • Reads, writes, metrics — the mechanisms and featurizers that fill in do and featurize