causalab-mini

Reference

The CLI

The command line: the loop an author lives in.

Two kinds of verb. Seven of them — schema, vocab, model, tokens, data, validate and explain — need no weights. They say what exists, what a document means and whether it would compile, in seconds on a laptop, against a model that may only ever run on someone else's GPU. run is the eighth, and the only one that loads the weights.

Every verb takes --json, so an agent parses instead of scraping. Every verb writes to stdout and nothing else; only run touches the disk.

At a glance #

The loop is check, read, run. All three commands below take the same document, the smallest interchange in the repository: four rows, one swap at the last token of layer 0 of a tiny random-weight Llama. Documents is the page for what is in it.

$ uv run causalab-mini validate documents/v2/patching.json
ok: documents/v2/patching.json is a valid plan-shaped document, 1 step

$ 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'

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

The whole run took 7.9 s on twelve CPU cores, model load included. run prints the paths it wrote, in the order it wrote them, and that is its entire output.

The verbs #

verbargumentwhat it needswhat it prints
schema—nothingthe JSON Schema of a plan-shaped document
vocab—nothingcomponents, mechanisms, featurizer kinds, metric kinds, position forms
modela HuggingFace keythe model's configlayers, padding side, and which components resolve, with widths
tokensa key and one or more stringsthe tokenizerhow many tokens each string is
dataa dataset refthe data rootrows, columns, empty cells, splits, one sample
validatea documenta meta shellwhether it compiles, and in which of the two formats
explaina documentthe model's configthe compiled plan
runa documentthe weightsthe paths it wrote

The options are few. model and tokens take --revision (default main); model also takes --dtype, one of fp32 and bf16. data, validate, explain and run take --data-root, which defaults to documents/data — a relative path, so these commands expect to be run from the repository root.

the cost is the import

Every verb pays about 6.3 s starting up, because importing causalab_mini.cli imports torch and nnsight. That is the whole cost of schema, vocab and validate: measured here, import causalab_mini.cli alone is 6.27 s and validate is 6.23 s. Budget for it in a loop; do not read it as the work.

Asking what exists #

vocab is the vocabulary a document is written in: every component you may name, every write mechanism, every featurizer and metric kind, and the forms a position may take. It loads nothing.

$ uv run causalab-mini vocab
components:
  attention_query        interior  [heads]
  attention_key          interior  [heads]
  attention_scores       interior  [heads]  needs eager attention
  attention_probs        nnterp attention_probabilities  [heads]  needs eager attention
  attention_z            interior  [heads]
  input_ids              nnterp embeddings_input  (read-only)
…
  block_output           nnterp layers_output
  ln_final               nnterp ln_final_output
  lm_head                nnterp lm_head_output
  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

schema prints the JSON Schema pydantic derives from Spec, which is the plan-shaped format itself: 1,116 lines, 22 KB, every field with the docstring that explains it. It is too long to read as text and exactly the right size for a tool. Its definitions are the document's nouns:

$ uv run causalab-mini schema | jq -r '."$defs" | keys | join(", ")'
Anchor, Anneal, CrossEntropy, EarlyStop, Evaluation, Featurizer, Fit, Header, IntervenedModel, Intervention, LogitDiff, Match, Model, Observe, Optimizer, Output, Read, Reference, Role, Save, Site, TokenLogit, TokenProb, Weights, Where, Write

model is the same question asked of a checkpoint: how many layers it has, which side it pads on, and which of the components in vocab actually resolve on it — with each one's width, which is what a featurizer has to match. It needs the config, not the weights.

$ uv run causalab-mini model hf-internal-testing/tiny-random-LlamaForCausalLM
hf-internal-testing/tiny-random-LlamaForCausalLM via nnterp: 2 layers, padding_side=left
  attention_query    ok   width=16  op=attention_interface_1
  attention_key      ok   width=16  op=attention_interface_1
  attention_scores   --   component 'attention_scores' exists only under 'eager' attention, and this model runs 'sdpa': say "attn_implementation": "eager" in the document's model block
  attention_probs    --   component 'attention_probs' exists only under 'eager' attention, and this model runs 'sdpa': say "attn_implementation": "eager" in the document's model block
  attention_z        ok   width=16  op=attention_interface_1
  input_ids          ok   no width (no featurizer here)
…
  mlp_activation     ok   width=64
  mlp_neuron_output  ok   width=64
  mlp_output         ok   width=16
  block_output       ok   width=16
  ln_final           ok   width=16
  lm_head            ok   width=32000
  logits             ok   width=32000
the verb cannot ask for eager attention

model builds its model spec from --dtype and --revision only, so it always asks for the library default (sdpa) and attention_scores and attention_probs always report --. A document can say "attn_implementation": "eager" in its model block and then both resolve; the verb has no flag for it. Use explain on the document to check that case.

tokens answers one question a metric depends on: is this string one token? A match or logit_diff column is read at a single position, so a two-token answer cannot be scored there. The tiny fixture's tokenizer is a good demonstration of why you ask rather than assume.

$ uv run causalab-mini tokens hf-internal-testing/tiny-random-LlamaForCausalLM " Monday" " Tuesday" "Sunday"
' Monday'                1 token(s)
' Tuesday'               3 token(s)  <- not usable as a metric column
'Sunday'                 1 token(s)

data reads a dataset ref the way a document's rows block does, and reports what is in it. empty is the column that matters most: a null or blank cell is an excluded measurement for any metric naming that column, and it is better to know before the run than after.

$ uv run causalab-mini data weekdays/train
weekdays/train: 4 rows
  columns: answer, base_answer, cf_answer, counterfactual_inputs, counterfactual_inputs_variables, entity, input, label, split
  splits:  all
  sample:  {"answer": " Friday", "base_answer": " Friday", "cf_answer": " Sunday", "counterfactual_inputs": ["If today is Saturday, tomorrow is"], "counterfactual_inputs_variables": [{"entity": "Saturday"}], "entity": "Thursday", "input": "If today is Thursday, tomorrow is", "label": " Sunday", "split": "all"}

The empty: line appears only when something is empty. The same table with one row's cf_answer removed, under a copied --data-root:

$ uv run causalab-mini data weekdays/train --data-root out/cli/holed-data
weekdays/train: 4 rows
  columns: answer, base_answer, cf_answer, counterfactual_inputs, counterfactual_inputs_variables, entity, input, label, split
  empty:   cf_answer (1 rows)
  splits:  all
…

Checking a document: one question, printed or not #

validate and explain do the same work. Both load the model as a meta-device shell — the config, the module tree, the tokenizer, no weights — resolve every address, tokenize every prompt and compile the plan. explain prints it; validate says whether it compiled, and in which of the two formats the file is written. Both take --data-root and --engine, and both use them.

$ uv run causalab-mini validate documents/v2/patching.json
ok: documents/v2/patching.json is a valid plan-shaped document, 1 step

$ uv run causalab-mini validate documents/pos_sweep_cpu.json
ok: documents/pos_sweep_cpu.json is a valid protocol document, 3 steps

So a misspelled component fails at validate: a component name is a question about a model, and this verb has one.

$ uv run causalab-mini validate out/cli/typo.json
AddressError: component 'block_ouput' has no address here, and this model's internals have no accessor of that name either. Mini's are ['attention_input_norm', 'attention_key', 'attention_output', 'attention_premix', 'attention_probs', 'attention_query', 'attention_scores', 'attention_z', 'block_input', 'block_mid', 'block_output', 'embeddings', 'input_ids', 'lm_head', 'ln_final', 'logits', 'mlp_activation', 'mlp_input', 'mlp_input_norm', 'mlp_neuron_output', 'mlp_output']; add one to a model with RenameConfig(addresses={...})

A refusal is a message on stderr and exit 1, not a traceback. The entry point catches this package's own error types by name — PlanError, DocumentError, AddressError, TokenError, LocateError, DataError, EngineError, pydantic's ValidationError and nnterp's RenamingError — and prints what they say. Nothing generic is caught, so a missing file is still a traceback. --traceback, before the verb like --json, restores the whole thing.

One exception, and it is a run through --engine ndif. nnsight flattens whatever the server raised into RemoteError, which is not one of the nine, so a remote refusal comes back as a traceback — 45 lines of it, ending in the same text and the same exit code. When the server refuses has the transcript and what the fix would be.

run #

flagdefaultwhat it does
--outoutthe directory the save manifest is written under
--enginennterpnnterp, ndif or hooks
--device-mapautopassed to the loader; ignored by ndif, whose weights are the server's
--batch-sizeevery row at oncerows per model call
--data-rootdocuments/datawhere a dataset ref is resolved from

There is no --dry-run, no --verbose and no --force. explain is the dry run; the output is the paths; and nothing is ever refused for already existing — see the caution under the output layout.

--engine: three runtimes, one plan #

--engineclassweightswhere it runs
nnterpNNterpEngineloaded here, under --device-mapthis process, in one nnsight session
ndifNNterpEngine, dispatch=Falsenone locally — a meta shellan NDIF server, which has the weights
hooksHooksEngineloaded herethis process, one register_forward_hook per tap

Compiling is a weightless shell for all three, so explain prints the same plan whichever engine you name — for documents/v2/patching.json the three outputs are identical here. What differs is which addresses a runtime accepts, and where the forward happens.

The hooks engine is a plain HuggingFace model with a forward hook on each module a tap names. A hook fires at a module's boundary and sees nothing inside it, so the four interior components — the query, the key, the scores and the attention output before the out-projection — are refused by name rather than approximated. Everything else it reaches, including logits: a hook is handed the module's whole value and nnterp's row says where the tensor is inside it, which for the model's own output is the logits field. model --engine hooks is how you find out without running anything:

$ uv run causalab-mini model hf-internal-testing/tiny-random-LlamaForCausalLM --engine hooks
hf-internal-testing/tiny-random-LlamaForCausalLM via hooks: 2 layers, sdpa attention, padding_side=left
  attention_query    --   component 'attention_query' is an interior — one argument of one call inside a module's forward — and a forward hook only sees the module's boundary. It is addressed through nnsight's `.source`, which has no hooks equivalent; run this document on the nnterp engine.
…
  block_output       ok   width=16
  ln_final           ok   width=16
  lm_head            ok   width=32000
  logits             ok   width=32000

Everything a hook can reach, it reaches identically. Running documents/v2/patching.json on both engines here produced byte-identical iia.json and logit_diff.json; the run took 6.7 s on hooks against 7.9 s on nnterp. The two run.json files differ in the engine name, and in that only the nnterp one has a served_dtype: that field is read off the model inside the session, and only the nnterp engine has one.

There is no flag that runs hooks remotely, and the engine refuses one by name if it is asked: a hook is a Python callable registered on a module object in this process, and NDIF runs the model in another one, so there is nothing here to ship.

--engine ndif loads the same nnterp shell with dispatch=False — module tree and tokenizer, no parameters — and executes the compiled plan inside model.session(remote=True). --device-map is not passed at all. The model key must name a model the server hosts, and the run record says "remote": true. Remote & NDIF is the page for it.

this machine cannot finish a remote run

Run here, --engine ndif reaches the service — the job is received, queued, dispatched and started — and then comes back with RemoteError: Your request payload could not be read (AttributeError: Can't get attribute '_replace' on <module 'dataclasses' from '/usr/local/lib/python3.12/dataclasses.py'>). The server's Python is 3.12 and this client's is 3.13. Everything on this page was therefore run locally, and what --engine ndif does is read off the code rather than demonstrated.

--batch-size #

A window of rows is a whole small pass: the same forwards in the same order over a slice of the rows, with each published value sliced the same way so a write still meets the operand of its own row. What the windows read is concatenated back in row order, and everything after that — metrics, eligibility, outputs, saves — sees one pass. With no --batch-size there is one window, which is the pass exactly as compiled.

It is a property of the run and not of the experiment, which is why it is absent from document.json and present in run.json. It is not free of consequence: a GEMM over fewer rows rounds differently. The four rows of documents/v2/patching.json, run whole, in twos and in threes:

--batch-sizelogit_diff, row 3
absent-0.19330616295337677
2-0.19330616295337677
3-0.19330619275569916

At 2 both tables came out byte-identical to the unbatched ones here; at 3 — which does not divide the four rows — the last digits of one row move. Do not count on either: FINDINGS.md §18 records the same comparison across patching, windows, the logit lens, generation, heads, neurons, a ragged harvest and a DAS fit, at batch sizes 1 and 3, and what it pins is agreement to 1e-6, not bit-equality.

What it does not do is bound the memory of a fit's update. The loss is the mean over the concatenated rows, so every window's graph is alive until the backward; a fit's memory knob is its pairs. It does bound the held-out pass, which runs under no_grad.

What lands on disk #

The plan's save manifest, and nothing else. Every file that leaves a run is a saves entry in the document, plus the document.json every plan's directory carries and the run.json the root carries. One document with one plan writes straight into --out:

out/cli/patch/
├── document.json      the experiment, verbatim, as it was given
├── run.json           what ran it
├── iia.json           a metric table
└── logit_diff.json

A nested plan writes below its parent in a directory named by its step name, so a plan's path in the tree is its path on disk. A sweep is exactly that: one root plan whose children are one plan per point, named for the field and the value it took.

$ uv run causalab-mini run documents/pos_sweep_cpu.json --device-map cpu --out out/cli/sweep
out/cli/sweep/document.json
out/cli/sweep/run.json
out/cli/sweep/pos=-1/document.json
out/cli/sweep/pos=-1/iia.json
out/cli/sweep/pos=-1/logit_diff.json
out/cli/sweep/pos=-2/document.json
out/cli/sweep/pos=-2/iia.json
out/cli/sweep/pos=-2/logit_diff.json
out/cli/sweep/pos=-3/document.json
out/cli/sweep/pos=-3/iia.json
out/cli/sweep/pos=-3/logit_diff.json

The directory name is the swept field's own name and the value it took, or the axis name when the sweep is a named one. The root's document.json is the document you wrote, sweep wrappers and all — its pos is still {"sweep": [-1, -2, -3]}. Each point's document.json is that point: the wrapper replaced by one value. So each point has its own digest, and each point's metric rows say which one produced them.

run.json is written once, on the root, because the engine records what ran on the plan it was handed.

A step that trains a featurizer writes it as a safetensors bundle alongside the tables, with its identity stamped into the header — the thing a later document's file_path load checks before trusting the rotation:

$ uv run causalab-mini run documents/v2/das.json --device-map cpu --out out/cli/das
out/cli/das/document.json
out/cli/das/run.json
out/cli/das/held_out_iia.json
out/cli/das/iia.json
out/cli/das/ce.json
out/cli/das/rot.safetensors
{
 "__metadata__": {
  "kind": "subspace",
  "k": "8",
  "d": "16",
  "parametrization": "cayley",
  "site": "target",
  "component": "block_output",
  "layer": "0",
  "model_key": "hf-internal-testing/tiny-random-LlamaForCausalLM",
  "model_revision": "9fb191250dd56d0ba7ec9785a025ed29c03d5998",
  "model_dtype": "fp32",
  "featurizer_dtype": "fp32",
  "produced_by": "11703a0d56a047e5d3fa157ef455534c8803e76b2058cc5f469a9358e065ff34",
  "engine": "causalab-mini"
 },
 "weight": {"dtype": "F32", "shape": [16, 8], "data_offsets": [0, 512]}
}

That is the safetensors header of rot.safetensors, read back from the file. The keys are grouped here for reading and the weight entry is on one line; the names and the values are the file's.

nothing is cleaned

--out is created if it is missing and written into if it is not. Files the run produces are overwritten; files from an earlier run that this one does not produce are left where they are. A directory reused across two different documents will hold both, with only document.json and the rows' produced_by to say so. Give each run its own --out.

run.json, annotated #

{
 "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"
}
fieldwhat it is
enginethe engine class that ran it: NNterpEngine for --engine nnterp and --engine ndif, HooksEngine for --engine hooks
remotewhat the engine was asked for. false here; true under --engine ndif
batch_size--batch-size, null when every row went in one call. Not part of the experiment, but it moves the last bit of every number
versionsthe five packages whose code decides what a block does: torch, transformers, nnsight, nnterp, pydantic. A package that is not installed reads "not installed"
causalab_minisha256 over this package's own .py files in path order. Two runs with the same digest ran the same code, whatever git says
served_dtypethe dtype the model was actually served in, read off lm_head.weight after the session. The document's dtype is a request; a server serves the dtype it chose. Set by the nnterp engine only

Deliberately absent: no hostname, no timestamp, no absolute path. A record has to be byte-identical across machines to be worth comparing.

A metric table row, annotated #

A metric table is a JSON array of row objects, one file per metric, with the labels repeated on every row so that jq and a person can both read it. Two rows of one table — the second one excluded — from documents/v2/entity_patch.json run against a data root whose row 1 has no cf_answer:

{
 "example_id": "0",
 "metric": "logit_diff",
 "value": -0.02950894832611084,
 "eligible": true,
 "positions": [
  10
 ],
 "reason": "",
 "tokens": "' is'",
 "unit": "logit",
 "estimand_version": "logit_diff/v1",
 "produced_by": "67d6465731d362667958723aa1faa600aca13bd2e4f78f73b895d38972af260c"
},
{
 "example_id": "1",
 "metric": "logit_diff",
 "value": null,
 "eligible": false,
 "positions": [
  10
 ],
 "reason": "",
 "tokens": "' is'",
 "unit": "logit",
 "estimand_version": "logit_diff/v1",
 "produced_by": "67d6465731d362667958723aa1faa600aca13bd2e4f78f73b895d38972af260c"
}
columnwhat it is
example_idthe row's example_id column if the table has one, else its index as a string
metricthe result this file holds, by name — the value of the save
valuethe number, or null. JSON has no NaN or Infinity, so a non-finite result is null too
eligiblefalse when the measurement was excluded. Two halves: the column half, decided from the data — this row had no answer to score — and, for a run that anchored a position to text, the rows it could place. The result holds one value per eligible row; an excluded row is still a row of the table, so it can never be read as a zero or silently shorten a denominator
positionsthe absolute token indices, in the padded batch, that this row's number was read at. null when the pass has no text anchor and no cut of the continuation — then the document already fixed the integers and the run has nothing to report
reasonwhy positions is empty when it is. Three of them: alignment_missing, the anchor text or the segment is not in this row; alignment_ambiguous, it is there more than once, and scoping the anchor is what makes it one; out_of_range, the cut falls outside the run it cuts. "" when the window was found
tokenswhat those positions say, decoded — the answer to "of what token?". "'day'" where the window is the last piece of " Thursday". Empty for a row with no window, and for a pass that reports nothing
unitfraction, logit, nat or probability, by metric kind
estimand_versionwhich definition of the metric produced the number: match/v1, logit_diff/v1, and so on
produced_bysha256 of the experiment: the whole document except header, which is authoring metadata. Two rows with the same digest came from the same experiment

Row 1 above is the column half: its cf_answer is null, so logit_diff has nothing to score, and value is null with eligible: false. Its positions is still [10], because the position resolved fine — it was the data that was missing, and the two halves are reported separately for that reason.

--json, for agents #

Each verb returns one dictionary with a text key. --json prints the dictionary; without it, only text is printed. So the human output and the machine output are the same run, never two code paths that can disagree.

--json is on the top-level parser, so it goes before the verb:

$ uv run causalab-mini --json validate documents/v2/patching.json
{
 "text": "ok: documents/v2/patching.json is a valid plan-shaped document",
 "ok": true,
 "format": "plan-shaped"
}

$ uv run causalab-mini validate documents/v2/patching.json --json
usage: causalab-mini [-h] [--json]
                     {schema,vocab,model,tokens,data,validate,explain,run} ...
causalab-mini: error: unrecognized arguments: --json

The payloads are the answers a document-writing agent needs, in a shape it can query:

$ uv run causalab-mini --json tokens hf-internal-testing/tiny-random-LlamaForCausalLM " Monday" " Tuesday" \
    | jq -c '.texts | to_entries[] | {text: .key, one_token: .value.one_token}'
{"text":" Monday","one_token":true}
{"text":" Tuesday","one_token":false}

$ uv run causalab-mini --json model hf-internal-testing/tiny-random-LlamaForCausalLM \
    | jq -r '.components | to_entries[] | select(.value.resolves | not) | .key'
attention_scores
attention_probs

$ uv run causalab-mini --json vocab | jq -c '.metric_kinds, .units.match, .components.block_output'
{"match":["expected"],"logit_diff":["a","b"],"cross_entropy":["target"],"token_logit":["token"],"token_prob":["token"]}
{"unit":"fraction","estimand_version":"match/v1"}
{"accessor":"layers_output","path":null,"side":"output","interior":false,"layered":true,"seq_axis":1,"width":"hidden_size","read_only":false,"heads":false,"needs":null}

Two payloads are thin. explain adds only steps, the names of the root plan's steps — the plan itself is the text, and it is written to be read. On a swept document those names are the points, which is how you learn the directory names before the run makes them:

$ uv run causalab-mini --json explain documents/pos_sweep_cpu.json | jq -c '.steps'
["pos=-1","pos=-2","pos=-3"]

run adds written, the list of paths; the results are in those files, not in the reply.

$ uv run causalab-mini --json run documents/v2/patching.json --device-map cpu --out out/cli/patch
{
 "text": "out/cli/patch/document.json\nout/cli/patch/run.json\nout/cli/patch/iia.json\nout/cli/patch/logit_diff.json",
 "written": [
  "out/cli/patch/document.json",
  "out/cli/patch/run.json",
  "out/cli/patch/iia.json",
  "out/cli/patch/logit_diff.json"
 ]
}

Exit codes, and what an error looks like #

codewhen
0the verb ran
1an uncaught exception. The traceback is the error message
2argparse: no verb, an unknown verb, an invalid --engine, --json after the verb

There is no error handling in the command line at all: a refusal is an exception, and it arrives with its stack. That is deliberate — the frames say which of the compiler, the address layer, the walk or the engine refused — but it means the sentence you want is the last line. Three real ones, each trimmed to its first line and its last, which is where the sentence is. This one exits 1:

$ uv run causalab-mini run documents/attention_query_cpu.json --engine hooks --device-map cpu --out out/cli/q-hooks
Traceback (most recent call last):
…
causalab_mini.address.AddressError: component 'attention_query' is an interior — one argument of one call inside a module's forward — and a forward hook only sees the module's boundary. It is addressed through nnsight's `.source`, which has no hooks equivalent; run this document on the nnterp engine.

A document that does not match the schema fails in pydantic, which names the field by its path:

$ uv run causalab-mini validate out/cli/badmetric.json
Traceback (most recent call last):
…
pydantic_core._pydantic_core.ValidationError: 1 validation error for Spec
interventions.patching.metrics.iia.match.expected
  Field required [type=missing, input_value={'kind': 'match', 'of': '...form': 'space_prefixed'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.13/v/missing

And a refusal that only the run can make, because only the run has the rows. A read may skip a row it cannot place; a write may not:

$ uv run causalab-mini run documents/v2/entity_patch.json --data-root out/cli/neptune-data --device-map cpu --out out/cli/neptune
Traceback (most recent call last):
…
causalab_mini.plan.plan.PlanError: write 'patch' has nothing to write on row(s) {1: 'alignment_missing'}. A read may skip a row; a write may not — writing nothing somewhere is not an intervention, and the row would score as if one had happened. `alignment_ambiguous` is a value that is in the prompt more than once, and scoping the anchor is what makes it one

The same hole on a read is not an error. It comes home as a row of the table with eligible: false, positions: [] and reason: "alignment_missing" — which is the case tests/test_eligibility.py pins. Whether a row the run cannot place stops the run or is reported depends on whether something was to be written there, not on the data.

See also #

  • Documents — the format every verb takes, field by field, and the schema schema prints
  • Remote & NDIF — what --engine ndif does with the plan, and what a server needs to accept it
  • The engine — what run is doing between the compile and the files: eight members and one session
  • Layer sweep — these commands on a real model, with results that mean something
  • Reads, writes, metrics — the metric kinds behind unit and estimand_version
  • Positions — where positions, reason and tokens come from