Reference
Positions
Where along a sequence a read or a write acts — a spec, resolved per row.
A position is never an integer in a document. It is a spec with three
independent fields — which sequence (frame), which run of it
(scope), and how much of that run (exactly one of
index, span, last and
all) — and the integers come out of a row. One spec is written
once and every row answers it for itself, which is what lets
{"index": -1, "scope": {"variable": "entity"}} mean the last
token of this row's entity, whatever text that is and however many
tokens it takes.
Resolution happens inside the session, where the model is, against the
model's tokenizer — not on the client. On a remote run that is the
served checkpoint's own tokenizer, reached through nnsight's persistent
objects, so no tokenizer bytes travel and a document runs against a model
whose tokenizer the client never loads (FINDINGS §25.1). The resolver,
causalab_mini/ops/locate.py, imports nothing but the standard
library, torch and shapes.py for exactly that
reason.
At a glance #
$ uv run causalab-mini vocab
…
position forms: exactly one cut: index=int, span=tuple[int, int], last=int, all=bool
scope: variable=str, segment=str
frame: Literal['prompt', 'generated']; a bare integer is {"index": i}; -1 is the last token
Three positions out of one shipped document — the same spec on both sides of an interchange, and the bare integer that is the whole of the sugar:
documents/v2/entity_patch.json — the two reads and the write, verbatim "reads": {
"v_cf": {
"site": "target",
"pos": {
"index": -1,
"scope": {
"variable": "entity"
}
},
"model": "original",
"input": "counterfactual"
},
"logits": {
"site": "head",
"pos": -1,
"model": "patched",
"input": "base"
}
},
"writes": {
"patch": {
"site": "target",
"pos": {
"index": -1,
"scope": {
"variable": "entity"
}
},
"mechanism": "swap",
"operand": "v_cf"
}
},
The three questions #
| field | what it answers | default |
|---|---|---|
frame | which sequence: "prompt", or "generated" — what the decode produced | "prompt" |
scope | which run of it: an anchor naming a variable, a segment, or both | the row's whole content |
index / span / last / all | how much of that run. Exactly one of them | — |
They are independent: any cut of any scope in either frame. The resolver
answers them in that order — find the run, cut it, check the cut is inside
it — and a position with no scope is those same three steps with the run
being the row's whole content. That is why {"index": -1} and
{"index": -1, "scope": {"variable": "entity"}} cost the same
code.
The cut #
| cut | means | width |
|---|---|---|
{"index": i} | one token of the run; -1 is its last | 1 |
{"last": n} | the run's last n tokens; n must be positive | n |
{"span": [a, b]} | half-open, a up to but not including b; must run forwards | b − a |
{"all": true} | every token of the run | only a row knows |
The one sugar is that a bare integer is {"index": i}, so
"pos": -1 is the last prompt token. There is no other spelling:
a decode step is {"frame": "generated", "index": k}, in the same
vocabulary as everything else.
Negatives count from the end of the run, not of the row. Inside a
scope, {"index": -1} is the anchor's last token; with no scope it
is the prompt's. A span may mix signs, and then its width is not knowable
before the row is (shapes.py:142-143).
What you can ask for #
| you want | you write |
|---|---|
| the last prompt token | -1 |
| the last token of this row's entity | {"index": -1, "scope": {"variable": "entity"}} |
| every token of this row's entity | {"all": true, "scope": {"variable": "entity"}} |
| the last three prompt tokens | {"last": 3} |
| all tokens of the user turn | {"all": true, "scope": {"segment": "user"}} |
| the last token of the second user turn | {"index": -1, "scope": {"segment": "user[1]"}} |
| decode step 2 | {"frame": "generated", "index": 2} |
| the last generated token | {"frame": "generated", "index": -1} |
| where the model said the row's own answer | {"frame": "generated", "index": -1, "scope": {"variable": "said"}} |
| where the row stopped | {"frame": "generated", "index": -1, "scope": {"segment": "eos"}} |
Chat turns #
A segment is a run of the row's prompt that the frame located,
and in the prompt frame those are the turns of a conversation. What makes a
role a chat is its data: a role's field may hold a string, or a list of
{"role", "content"} messages, and a list is rendered through
the checkpoint's own chat template when the document is compiled. There is
no flag — the same shape an anchor already has, where what it looks for is
whatever the row carries under that name.
documents/data/weekdays/chat.json — one row's chat column "chat": [
{
"role": "user",
"content": "Name a weekday."
},
{
"role": "assistant",
"content": "Thursday."
},
{
"role": "user",
"content": "If today is Thursday, tomorrow is"
}
],
The compiler records the character span of each turn's
content, in the coordinates the resolver works in, and they
travel in the plan. A turn is named by its own role, and by
role[k] for the k-th of that role — the bracket a field path
already uses for a list. The bare name is recorded only for a role that
speaks once; asking for it when two did is
alignment_ambiguous, the rule a variable occurring twice
already gets.
forward.sample
'[INST] Name a weekday. [/INST] Thursday. </s><s>[INST] If today is Thursday, tomorrow is [/INST]'
forward.segments[0]
{"user[0]": [7, 22], "assistant[0]": [31, 40], "user[1]": [55, 88], "assistant": [31, 40]}
The template's own control tokens are between the turns and inside none
of them, because each content is found as itself. So a read at the last
token of the second user turn is the question's last token, where
-1 of the whole prompt is the template's closing
[/INST]:
{"index": -1, "scope": {"segment": "user[1]"}} ((32,), (32,), (32,), (32,)) "' is'"
{"index": -1} ((36,), (36,), (36,), (36,)) "']'"
{"segment": …, "variable": …} composes: the row's entity
searched inside the turn that asked the question, which is what makes it
unique when the assistant said the same word first. eos is the
one segment name a frame invents rather than reads, and it belongs to the
continuation; each frame refuses the other's.
Anchors: which text, on which row #
A variable anchor names a column, and the text it binds to is
the row's own. The rule is the <column>_variables sibling
of the role's field first, and a top-level column of that name otherwise:
counterfactual_inputs[0] with the variable entity
looks for counterfactual_inputs_variables[0].entity, then for a
column entity. A name that is neither is a misspelling, refused
on the client, naming both places it looked.
That rule is what lets one spec serve both roles of a pair. In
weekdays/train the base role's field is input and
picks up the top-level entity; the counterfactual role's field
is counterfactual_inputs[0] and picks up the sibling:
$ 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 compiler carries that text per row, in the plan, as the one input the
resolver cannot find for itself: locate.py has no dataset. The
spec and the per-row text travel together as
Selection.where and Selection.anchors.
When the value is in the prompt twice, or not at all #
| reason | when | what it means |
|---|---|---|
alignment_missing | the anchor's text is not in this row's run | an excluded measurement, or the wrong column |
alignment_ambiguous | it occurs more than once | scope the anchor, or name a longer value |
out_of_range | the cut falls outside the run it cuts | for a fixed-width cut this is refused, not reported — see below |
The search is in the decoded text, because decoding is what normalizes a
sentencepiece prefix space. It tries the value, then " " +
value, then the stripped value, and the first spelling that hits at
all decides — so " Thursday" occurring once is one hit even
though "Thursday" also occurs inside it. Anchoring to
"day" on these prompts is ambiguous, because it is in
today and in Thursday both — the last line of the
block below.
The character map, and why the last token of Thursday is day #
The map from characters to tokens is built from the ids, by decoding
growing prefixes: offsets[k] is the character at which token
k starts. It needs no fast tokenizer and no
offset_mapping, and it describes the ids the run will actually
feed the model rather than a re-encoding that may not reproduce them.
from transformers import AutoTokenizer
from causalab_mini.ops import locate
from causalab_mini.shapes import Anchor, Where
tok = AutoTokenizer.from_pretrained(
"hf-internal-testing/tiny-random-LlamaForCausalLM",
revision="9fb191250dd56d0ba7ec9785a025ed29c03d5998")
ids, mask, frame = locate.frame_of_texts(
tok, ["If today is Thursday, tomorrow is", "If today is Friday, tomorrow is"])
print(repr(frame.texts[0]))
print([tok.decode([i]) for i in ids[0][frame.starts[0]:frame.ends[0]]])
print(frame.offsets[0])
for where in (Where(all=True, scope=Anchor(variable="e")),
Where(index=-1, scope=Anchor(variable="e"))):
window, reason = locate.locate(frame, where, ("Thursday", "Friday"))
print(where.spelling(), window,
[locate.tokens_of(frame, one, row) for row, one in enumerate(window)], reason)
print(locate.locate(frame, Where(index=-1, scope=Anchor(variable="e")), ("day", "day")))
'If today is Thursday, tomorrow is'
['If', 'today', 'is', 'Th', 'urs', 'day', ',', 'tom', 'orrow', 'is']
(0, 2, 8, 11, 14, 17, 20, 21, 25, 30, 33)
{all scope:{variable:e}} ((4, 5, 6), (6,)) ["' Thursday'", "' Friday'"] ('', '')
{index:-1 scope:{variable:e}} ((6,), (6,)) ["'day'", "' Friday'"] ('', '')
(((), ()), ('alignment_ambiguous', 'alignment_ambiguous'))
A character span selects the tokens that overlap it, not
the ones it contains, because the addressed thing is a token. The anchor
"Thursday" starts at character 12 and the piece
' Th' starts at 11, so that piece is in the window and the
window decodes back to ' Thursday'. Cut that run at
-1 and you get its last token, which on this tokenizer is the
piece day.
{"all": true} over the entity is three tokens on a Thursday
row and one on a Friday row, so swapping one row's whole entity into
another's has no width to land in; mini refuses it, naming the rows.
{"index": -1} inside the entity is one token on every
row whatever the entity is, which is what makes the same interchange land.
"The last token of" is a question about tokens, and the answer here is a
word fragment.
Position 0, and BOS #
A row's run starts after whatever the tokenizer puts before every prompt,
and what that is, is asked rather than guessed: whatever the tokenizer makes
of the empty string is what it adds to everything. Llama's sentencepiece
prepends a BOS and GPT-2's BPE prepends nothing, so without this
{"index": 0} would be <s> on one family and
the first word on the other — the same document addressing two different
things, silently, which is the one thing a standardized position may not do.
from transformers import AutoTokenizer
from causalab_mini.ops import locate
from causalab_mini.shapes import Where
FIXTURES = {
"llama": ("hf-internal-testing/tiny-random-LlamaForCausalLM", "9fb191250dd56d0ba7ec9785a025ed29c03d5998"),
"gpt2": ("hf-internal-testing/tiny-random-gpt2", "71034c5d8bde858ff824298bdedc65515b97d2b9"),
}
for family, (key, revision) in FIXTURES.items():
tok = AutoTokenizer.from_pretrained(key, revision=revision)
ids, mask, frame = locate.frame_of_texts(tok, ["If today is Thursday, tomorrow is"])
window, _ = locate.locate(frame, Where(index=0))
print(f"{family:6s} prefix={locate._special_prefix(tok)!s:5s} ids[:4]={ids[0][:4]} "
f"starts={frame.starts} index 0 -> {window[0]} {locate.tokens_of(frame, window[0], 0)}")
llama prefix=(1,) ids[:4]=(1, 960, 9826, 338) starts=(1,) index 0 -> (1,) 'If'
gpt2 prefix=() ids[:4]=(41, 70, 227, 68) starts=(0,) index 0 -> (0,) 'I'
The tiny GPT-2's vocabulary is small enough to split If in
two, so what the test asserts is that the first token is the start of the
prompt's own text and not a marker the tokenizer added
(tests/test_locate.py:287-305).
The prefix itself is not addressable. It would be a run the frame locates,
like eos in the continuation — {"scope": {"segment":
"bos"}} — and no document has asked.
Left padding: the same index, a different token #
Resolution works from each row's half-open content span inside the padded
sequence, so it does not care which side the padding is on. What left padding
does mean is that every row's last token sits at the same index, and so does
everything counted back from it. Rows that share a tail therefore share that
tail's indices, and the index of the last token of whatever precedes it. The
four prompts of entity_patch.json differ only in the weekday and
all end , tomorrow is, so the last token of the entity is at
index 6 on every row and no two rows address the same piece. The integers say
nothing here and the decoded tokens say everything (see
Provenance below for the run that produced them).
The index moves when the tokens after the anchor differ. On
arithmetic prompts, where " Tuesday" is three tokens and
" Monday" one, the same spec lands at 8 on two rows and at 6 on
the other two (the same tok as above):
prompts = ["Q: What day is one days after Monday?\nA:",
"Q: What day is three days after Monday?\nA:",
"Q: What day is two days after Tuesday?\nA:",
"Q: What day is five days after Tuesday?\nA:"]
_ids, _mask, frame = locate.frame_of_texts(tok, prompts)
windows, reasons = locate.locate(frame, Where(index=-1, scope=Anchor(variable="number")),
("one", "three", "two", "five"))
print(windows, reasons)
print([locate.tokens_of(frame, one, row) for row, one in enumerate(windows)])
((8,), (8,), (6,), (6,)) ('', '', '', '')
["' one'", "' three'", "' two'", "' five'"]
Both facts are worth knowing. The decoded tokens are what show an anchored patch doing something per row; an assertion that the integers differ needs a corpus where they do.
Eligibility: a read may skip a row, a write may not #
A row whose anchor text is not in its prompt gets an empty window. For a
read that is an excluded measurement: the row contributes no positions, the
run reports the reason, and it stays a row of the table. For a write it is
refused — writing nothing somewhere is not an intervention, and the row would
score as if one had happened. The message names each row and that row's own
reason, because the three have three different fixes
(tests/test_ragged.py:243-297 asserts both shapes):
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
An empty window means two different things and they get opposite answers.
An anchored cut that found nothing is data, reported per row. A cut
of a fixed width that a row cannot fit — {"last": 12}
on a nine-token row, {"index": 40} on any of these — names the
same number of tokens on every row, so a row it does not fit is a document
that is wrong about its own prompts. That is refused where it is resolved,
naming the op, the rows and the reason.
A metric's rows are the intersection of two halves. The column
half is the client's — whether the data had an answer to score. The
position half is the run's — whether it could place the read.
Neither is authoritative alone, and the run reports the intersection as
results["eligible"]. A metric none of whose rows survive both
halves is refused: a metric of nothing has no mean
(steps.py:207-212).
Provenance #
A pass with a position the document does not already fix records where it
acted. results["positions"] holds, per op, each row's
rows window, the reason it is empty when it is, and
the tokens it addressed — plain tuples of integers and strings,
so they come home in the plan like a metric does.
A pass whose every position is a bare integer resolves the same numbers on
every row and every run, and the document already says so: it builds no
character map and reports nothing. The map is the expensive half — O(L)
decode calls of O(L) work per row — and only a pass that reports where it
acted has any use for it (locate.py:85-91).
$ uv run causalab-mini run documents/v2/entity_patch.json --out out/entity_patch
out/entity_patch/document.json
out/entity_patch/run.json
out/entity_patch/iia.json
out/entity_patch/logit_diff.json
A write's record has no table to live in, and for that the
plan the run filled in is where to look. Save this as
where.py:
import json
import sys
from causalab_mini import plan
from causalab_mini.engine import NNterpEngine
from causalab_mini.plan.spec import Spec
raw = json.loads(open(sys.argv[1]).read())
engine = NNterpEngine.load(Spec.model_validate(raw).model, device_map="cpu")
done = engine.execute(plan.build_request(raw, "documents/data", engine))
results = done.step("score", plan.Observe).results
for name, record in results["positions"].items():
print(name, record["rows"], record["tokens"], record["reason"])
print("eligible", results["eligible"])
$ python where.py documents/v2/entity_patch.json
v_cf ((6,), (6,), (6,), (6,)) ("' Saturday'", "' Sunday'", "'day'", "' Friday'") ('', '', '', '')
patch ((6,), (6,), (6,), (6,)) ("'day'", "' Friday'", "' Saturday'", "' Sunday'") ('', '', '', '')
logits ((10,), (10,), (10,), (10,)) ("' is'", "' is'", "' is'", "' is'") ('', '', '', '')
eligible {'iia': (True, True, True, True), 'logit_diff': (True, True, True, True)}
Row 0's base entity is Thursday and its counterfactual's is
Saturday: the write lands on the piece day where
the read took ' Saturday' whole. Both are at index 6.
All three reach disk. Every row of a metric table carries
positions — where the number was read —
reason — why it was nowhere — and tokens, what
that window says. The window is the one the metric's own read resolved
to, so on this document it is the logits read at
-1 and not the entity:
out/entity_patch/iia.json — row 0 of 4, as written{
"example_id": "0",
"metric": "iia",
"value": 0.0,
"eligible": true,
"positions": [
10
],
"reason": "",
"tokens": "' is'",
"unit": "fraction",
"estimand_version": "match/v1",
"produced_by": "67d6465731d362667958723aa1faa600aca13bd2e4f78f73b895d38972af260c"
}
hf-internal-testing/tiny-random-LlamaForCausalLM is a
fixture. Its iia of 0.0, and the probabilities further down,
are what the arithmetic produced and say nothing about any model. The
positions and the decoded tokens are real either way: they are the
tokenizer's, and the tokenizer is a real one.
An excluded measurement is still a row of the table, with
"value": null and "eligible": false, so it can
never be read as a zero or silently shorten a denominator.
The generated frame #
{"frame": "generated"} addresses what the decode produced.
The decode runs to its bound — mini holds EOS off so the loop is a bound and
the batch stays rectangular — but how far a row meant to generate is
a result, so the frame is cut per row at that row's first stop token and the
stop token is the last one in it. A row that never stopped has the whole
bound and no eos segment.
Its character map is built from the ids the decode produced, never from re-encoding the finished text: a tokenizer is free to merge across a boundary the decode never saw.
{"index": k} with k >= 0 is decode step
k, and the tap fires there. Every other cut of the continuation
— the last generated token, the stop token, where the model said the answer —
is of text that does not exist until the decode has run, so the compiler
emits one ordinary read per step, each carrying the same spec and the name of
the stack it belongs to, and the walk stacks them and cuts them against the
continuation afterwards. Neither engine had to learn to buffer. The cost is
stated where it is paid: rows × decode × width numbers, refused
above 256 MiB (build.py:931-940, 951-965).
A write is the other case. It happens during the decode, so it may only name a step the run has already reached:
a write with "frame": "generated" | what happens |
|---|---|
{"index": k}, 0 <= k < decode | writes at that step |
{"all": true} | writes at every step — this is steering |
{"index": -1}, {"last": n}, {"span": …} | refused: a cut of the finished continuation is something to read, not something to write into |
any scope | refused: it is only known once the decode has finished |
any form, with decode: 0 | refused: a generated position needs decode > 0 |
Every tap in this frame is reported by the continuation and not by the
prompt, stacked or not: the prompt frame has nothing true to say about a
position that is a decode step. So positions for a
generated-frame op is the step, and tokens is the token the
model produced there.
$ uv run causalab-mini run documents/v2/generated_answer.json --out out/generated_answer
out/generated_answer/document.json
out/generated_answer/run.json
out/generated_answer/p_answer.json
The same script as above, on that document:
$ python where.py documents/v2/generated_answer.json
at_said ((1,), (2,), (), ()) ("' substr'", "' energ'", '', '') ('', '', 'alignment_missing', 'alignment_missing')
at_stop ((), (), (), ()) ('', '', '', '') ('alignment_missing', 'alignment_missing', 'alignment_missing', 'alignment_missing')
eligible {'p_answer': (True, True, False, False)}
at_said is {"frame": "generated", "index": -1,
"scope": {"variable": "said"}}: the last token of this row's own
said text, in what this row generated. Two of the four rows said
it — at step 1 and at step 2 — and two did not, and that is a reported result
rather than an exception that ends the run. The table keeps all four rows:
out/generated_answer/p_answer.json — rows 0 and 2 of 4, as written[
{
"example_id": "substr",
"metric": "p_answer",
"value": 3.008824569405988e-05,
"eligible": true,
"positions": [
1
],
"reason": "",
"unit": "probability",
"estimand_version": "token_prob/v1",
"produced_by": "8dcb96076c71ff1883ad97ecead2ece4ae24d447a121d013bb60e0155198f334"
},
…
{
"example_id": "Friday",
"metric": "p_answer",
"value": null,
"eligible": false,
"positions": [],
"reason": "alignment_missing",
"unit": "probability",
"estimand_version": "token_prob/v1",
"produced_by": "8dcb96076c71ff1883ad97ecead2ece4ae24d447a121d013bb60e0155198f334"
},
…
]
at_stop asks {"scope": {"segment": "eos"}} and
every row reports alignment_missing. That is not the document
being wrong: mini decodes with min_new_tokens == max_new_tokens
so the loop is a bound, so no row ever stops, so nothing has an
eos segment. "Did the model stop?" comes home as a reported
reason. The positive case is reachable only over hand-made ids, which is
where it is tested (tests/test_locate.py:149-164).
Width and ragged #
Two properties of a spec, both knowable without a row, answering different questions.
| property | asks | value |
|---|---|---|
width | how many positions the cut names | 1 for index, n for last: n, b − a for a same-signed span; None for all and for a mixed-sign span |
ragged | whether a row may come back with a window the others do not have | true for a cut of varying width, and for any anchored spec however narrow the cut |
So {"index": -1, "scope": {"variable": "entity"}} has width 1
and is ragged: the cut names one token, and whether a row has that
one token at all is the other question. {"all": true} has no
width and is ragged for the first reason. width is what lets the
compiler check a write against its operand before either has been resolved;
ragged is what decides that the gather comes back flat,
(total, width), one row per row that resolved — see
Reads, writes, metrics for what a read and a write
then do with it.
A metric reads width == 1 and nothing else. Ragged is fine:
p_answer above reads a ragged width-1 window and scores the two
rows it found. {"all": true} or {"last": 3} under a
metric is refused at validation.
In the continuation frame ragged is about the steps a row
keeps, not the positions a tap acts at: whatever the spec names, a decode
step processes one position, and the cut happens afterwards over the stack of
steps.
What is refused, and where #
| refusal | where |
|---|---|
two cuts, or none; {"last": 0}; a backwards span; segment: "eos" in the prompt frame; an anchor naming neither a variable nor a segment | validation, by pydantic, with the path to the key |
| a metric over a window that is not width 1; a write and its operand of different fixed widths | validation |
a generated position with decode: 0; a step past the budget; a write naming a cut of the finished continuation, or taking a scope | validation |
| a write whose referenced output was read over a different window, or over an unreduced ragged read | compile |
| a continuation read that would buffer more than 256 MiB of decode steps | compile |
| a fixed-width cut that a row cannot fit | the run, where the positions are |
| a write with nothing to write on a row; a ragged write whose operand is a different width on some row | the run |
| a metric no row is both column-eligible and placeable for | the run |
| a tokenizer here that disagrees with the one that encoded the plan | the run, by decoding row 0 on both sides and comparing the strings |
That last check compares decoded strings rather than re-encoding, because
encode(decode(ids)) == ids is not a property tokenizers have: it
holds for ASCII on both fixtures here and fails on the first emoji. One row
is a sample, so a skew that first shows on row 7 passes.
See also #
- Reads, writes, metrics — what a read and a write actually do with the window a position resolves to, and why a ragged read comes back flat
- Locations — the other half of an address: which component and which layer, and how heads and units cut the feature axis
- Documents — where
possits in a document, and the roles and columns an anchor binds against - Entity patching —
entity_patch.jsonandentity_mean_ablation.jsonend to end: this vocabulary doing one experiment - Generation — the continuation frame as a worked example, including the per-step sweep
- Remote & NDIF — why the resolver runs in the block, and how it reaches the served checkpoint's own tokenizer