Skip to content

0.8 skills audit: every block executes and asserts what it claims - #7

Merged
JadenFiotto-Kaufman merged 53 commits into
0.8from
audit/0.8
Sep 3, 2026
Merged

JadenFiotto-Kaufman merged 53 commits into
0.8from
audit/0.8

Conversation

@JadenFiotto-Kaufman

Copy link
Copy Markdown
Member

The skills half of the 0.8 release audit. The core finding of the whole pass surfaced here: every fenced block was already executed by the harness, but almost none asserted — so a block that ran to completion with a wrong answer stayed green forever. That is how the logit-lens skill printed <|endoftext|> for all thirty layers with a passing test. Every block this PR touches now asserts the thing it claims, as does every block a skill's description advertises as its headline result.

Results that changed when measured

  • causal-tracing: the advertised GPT-2 two-site result was two stacked artifacts (an architectural identity plus one noise seed). The ROME protocol was re-run at 8B: on Qwen3-8B the two-site structure reproduces from single-state restoration, stable across ten seeds. The skill moved to that model, with a noise-scale sweep judged by the worst seed.
  • model-steering: the coefficient sweep's α=0.25 row had never run (a bounded loop silently dropped its tail) — "nothing happens at 0.25" was a stale variable. Re-measured: the output moves without carrying the concept, which is why you sweep. The dead entropy proxy is replaced by measures that actually fall.
  • sae-and-dictionary-learning: a rotated identity map defeated four of the five documented metrics (loss recovered 1.0000, on held-out data). The L0 = d_model tell, the held-out rule, and the null itself are now runnable blocks.
  • circuit-discovery: faithfulness had an unreachable zero — all 144 heads ablated scored above the random control. Rebuilt on the reachable floor: circuit 95.7%, random −2.0%, a deliberately wrong circuit −0.1%, asserted.
  • attribution-patching: the flagship validation step was fp32 rounding noise divided by fp32 rounding noise. Replaced with a non-degenerate sweep that asserts its effects have variance.
  • nnterp: nnsight 0.8's label shift broke attention_probabilities on 5 of 7 families; with the drilling fix upstream, all 12 blocks run and assert real probability matrices.
  • quantization / tensor-parallel / vllm / diffusion: the fp8 silent fallback, the ≥5.16 floor, the tp_plan="auto" bypass, per-rank weight reductions, the one-step diffusion trace, and the vLLM row-count rule are all corrected against measurement; the TP and diffusion skills went from mostly-skipped to fully executed.

CI

The workflow now installs nnterp (its skill's blocks could never run on the runner) from the nnsight-0.8 branch, switching to PyPI once the coordinated release ships.

Gates: test_structure.py 119 passed; test_skills.py fully green — 260 blocks on CPU/A6000, 12 vLLM blocks on an A100.

🤖 Generated with Claude Code

JadenFiotto-Kaufman and others added 30 commits September 2, 2026 23:33
The three ways an agent gets stuck with `.source` were all missing, and all
three surface as the same `OutOfOrderError` about the model having run past a
location — a message that points away from the real cause:

- An operation on a branch this config never takes. GPT-2's attention lists 50
  operations and runs 28; `transpose_0` is the dead cross-attention key
  transpose and `transpose_2` the live one. New section, with an
  `expect-error` block.
- The first `.source` on a module landing after something else was read. The
  "all layers in one pass" example only worked because its requests happened to
  come in layer order; it now warms each module outside the trace.
- A drill asked for after the operation's own `.output`.

Also: `F.softmax` and friends are not drillable (a function that names itself
has no cell to bind), an assignment's `.output` need not be a tensor, and the
labels are anchored to transformers 5.15.

Every block now asserts its result rather than printing it — probability rows
sum to 1 and scores do not, patterns differ across layers, key length grows
across generation steps. 11 blocks, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A whole-value replacement has to keep the rows the block owns; a donor
activation captured at a different prompt length is how a patching sweep
gets a short one. The new block runs on SmolLM2 and asserts the message.

`.scan()` says what it raises rather than sitting in a list of things that
are unavailable. Errors from the worker come home as a `RuntimeError`
carrying the original type, so match the message; warnings and engine-build
failures do not come home at all, which is where to look for a bad `taps=`
entry.

Under graphs, an un-cloned list of captures is not uniformly wrong: every
decode entry holds the last step's values, but the prefill entry is its own
buffer, which is why such a list looks partly right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…default

- references/access-and-modify.md said calling an envoy runs its forward with "no
  hooks". A forward_hook registered on the module demonstrably fires twice: once
  for the real forward, once for the ad-hoc call. hook= names whether nnsight
  watches the call.
- The same file gains the rule that a replacement has to descend from the value
  it replaces if gradients are wanted, and the object-id error you get otherwise.
- .inputs is assignable and is the only way to edit past the first argument.
- api-reference.md had tracer.cache(non_blocking=True); the signature is False.
- tracer.result is served after the forward, so it goes after model.output.
- NameError alongside UnboundLocalError for an unsaved name at module scope.
- The SKILL.md orientation block and the collection example now assert their
  results instead of printing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every row of the porting table was executed again, from an imported module under
untouched warning filters — the case that made the old table wrong. Deprecations
are `nnsight.NNsightDeprecationWarning` now, a `FutureWarning`, so the caveat
that a warning only shows in `__main__` is gone rather than softened: it holds
nowhere. Two rows that read "silent" have messages, `model.generator.output` and
the `with tracer.iter[...]:` block form, and three message texts changed.

The table is split by what the reader sees — raises immediately, runs and warns,
runs and says nothing — because that is what they have when they arrive. The
gradient row said the old `retain_grad()` idiom was broken; it runs and gives the
identical gradient, so it is not the first thing to change.

`model.generator.output` and `tracer.result` are the same tensor, prompt ids
included. The table implied otherwise.

Catalogue additions, all reproduced: the two capture-time refusals (a body on the
`with` line, a body whose first statement is a `try`), a nested `model.trace`,
`return` in a body, a script on stdin, `backward()` under `torch.no_grad()`, the
batched row-count refusal and how it arrives through vLLM, vLLM's `scan`, and the
barrier's call form — it is `b()`, not `b.wait()`.

SKILL.md: the empty-saved-list row is remote-only and now says so, `.save()`
marks by object identity rather than by variable name, and the triage table
carries the shapes that stop a block before it runs. Every block asserts what it
claims rather than printing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g subjects

The skill was the weakest of the four TP surfaces and the only version-wrong one.

- The floor is transformers 5.16, not 5.15, in the frontmatter description and
  the section heading. The reason given was the 5.14/5.15 tied-LM-head defect;
  the actual floor is that 5.16 moved the plan onto the model.
- `tp_plan="auto"` does not raise `TypeError`. It is accepted, shards correctly,
  and skips nnsight's degree check, so gpt2 under torchrun loads whole on every
  rank in silence. The diagnosing row is now that symptom.
- `grouped_gemm` and `ep_router` are supported. Both were exercised end to end on
  Qwen1.5-MoE-A2.7B at ep=2. Only `megamoe_router`, `megamoe_experts`,
  `moe_identity_expert` and `mla_kv_a_proj` are refused, and a plan containing
  one produces `UnshardableCheckpoint`, not `UnsupportedParallelStyle`.
- The headline edit example raised at tp=2 and tp=4. Clone, edit, assign back,
  now stated as rule 3 alongside the failing form.

Added: the clone rule; `gather`/`shard` with the head-ablation example;
parameters as DTensors with the per-rank reduction trap and the `aten.fill_`
in-place limitation; `max_tp_size` as a pre-flight, which is the skill's first
executed block; the conditional `embed_tokens.output` constraint; the three
launch failures; and bfloat16 versus float32 drift.

Verified on hakone, 2 and 4 A100s, transformers 5.16.1 / torch 2.9.1.
tests/test_structure.py 119 passed; the skill's blocks ran=1 compiled-only=12.
SKILL.md's headline generation rule said to bound the loop; the rule is that a
loop must not ask for a step the run does not make, with min_new_tokens= as what
makes a bound hold.

references/generation.md — rewritten around that rule: the bounded over-run
raises, the open loop warns and drops its trailing statements, the empty-invoke
form, the empty-body hang, and the one-step-late write. Blocks assert their
claims rather than printing them.

references/control-flow.md — the pass-through example now feeds a block its own
input (the previous one zeroed the MLP and called it a pass-through); the
replacement contract covers dtype and device and says the failure arrives as a
bare torch error; the stop section adds that the run's result is unreachable
afterwards and that saving it first runs the whole pass; a trace body cannot
start with try:.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep pattern taught a nnsight.save([]) appended from every invoke and
read back by position. That is only safe because every invoke in the
example appends at the same module; vary the module and the list comes
back in model-reached order with no error. Switched to a dict keyed by the
loop variable, with the failing case shown and asserted.

Added the padding section: every invoke is padded to the batch's longest
input, so [:, -1] is stable and any absolute index is not, and pad
positions carry activations whose norm can exceed the real tokens' — both
asserted.

Rewrote the sharing and barrier sections around what actually decides
whether a cross-invoke name is readable: where the reading worker has
parked. A barrier is for the consumer that cannot park past the producer,
which includes every consumer whose first statement is a write. Added what
an over-counted barrier does, since it surfaces as a NameError nowhere
near the barrier.

Three rows added to the constraints table: a batched write must keep its
rows, tracer.stop() halts the shared forward rather than one invoke, and
direct input does not combine with invokes.

All 12 blocks run and assert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t run

The eager column of the taps table was tokens per second, which is not a
property of the card: an eager engine spends a Python round trip per module call
on the driver, so it follows the host's spare CPU (8B plain generation measured
at 86 tok/s on a quiet box and 54 on a busy one, where a tapped engine gives 89
on both). It is published as a share of vanilla now, with the measurement that
it is vLLM's cost and not nnsight's — plain `vllm.LLM(..., enforce_eager=True)`
and `VLLM(...)` generating with no trace measure the same four times over.

`VLLM_WORKER_MULTIPROC_METHOD=spawn` is not something you set: vLLM sets it once
CUDA is initialized, which dispatching does. What it costs you is the `__main__`
guard, on one GPU as much as on eight.

`nnsight-serve` gains the two limits that are silent — short flags dropped, and
no CLI spelling for `taps=` — and the reference gains an async section: saved
names are not pushed into your frame, the stream is consumed once and a second
await answers `None`, and one engine belongs to one event loop.

Pipeline parallelism now names the error it fails with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CROSS_INVOKER row told a porting reader to reach for `tracer.barrier(n)`
"when writing across the same module". The module is not the variable, and the
real rule — a barrier is for a consumer that cannot park past the producer's
location before reading — is homed in `docs/usage/invoke-and-batching.md`. Point
there; invokes share values without help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…neration steps

caching-and-scan.md: replace the invented "only modules reached after the call"
rule with the ValueError that actually fires; add the per-invoke padding hazard
with measured norms and the empty-invoke escape hatch; detach=False as the
differentiability switch; a measured cost table showing a cache and a save loop
are the same speed (19.0 vs 18.8 ms) and unfiltered is 20x.

model-editing-and-lora: the persistent-edit demo printed the same token as the
baseline, so it demonstrated nothing - now it changes the prediction and asserts
it. Add the two boundaries an edit does not cross. The adapter-in-an-edit example
ran at prefill only; put it under tracer.iter[:] and count the steps with a cache
(1 of 5 without, 5 of 5 with). Note PEFT's base_model.model re-rooting and the
.to_local() requirement for weight edits under tensor parallelism.
Every diffusion block in this skill was `test: skip` behind "verify against your
own pipeline", and three of them were wrong.

The "diffusion lens" section described per-timestep latent decoding. The
published technique (Toker et al., ACL 2024) probes the *text encoder*: read an
intermediate layer, push it into the encoder's final layer norm, render. That
section is now the real thing, executed on stabilityai/sd-turbo, and asserts what
it claims -- the image from layer 21 is closer to the full-encoder image than the
one from layer 0 (mean |diff| 0.0926 vs 0.2296).

It also decoded the wrong tensor. `unet.conv_out.output` is the predicted noise,
not the latent. Across six steps, correlation with the finished image:

    latent (unet.inputs[0][0])  0.362 -> 0.838
    conv_out                    0.333 -> 0.219

Decoding intermediate latents is now its own section under its own name, with the
`.clone()` the reused latent buffer requires and the guidance-doubling note.

The DINOv3 path `vision.encoder.layer[6]` does not exist; it is
`vision.model.layer[6]`, `[1, 201, 384]`. The block ran for the first time and
caught it.

The image-ablation demo compared `argmax` on a token that does not move, so it
printed 'The' against 'The' and read as a broken technique. Top-5 shows the
distribution moving (This 0.324 -> 0.026) and generation shows the answer
changing ('The color of the image is not specified in the caption.' vs 'The color
is this.').

`model.tokenizer` is populated on every VLM tested; it is `None` for tasks with
no text side, which is the real rule. `scan` names the error it raises
(`GuardOnDataDependentSymNode`) and which families it hits.

New: the traced-run one-step default, the scheduler's denoiser call count (PNDM
makes N+1), the open-`iter[:]` hang, in-loop ordering, the four measured VLM
module layouts, the processor-builds-the-ids rule, and chunked tasks -- one row
per chunk in one forward, the two dict-input tasks, and mask-generation's
refusal.

All 17 blocks execute; the six diffusion ones are `test: gpu`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalogue only had the loud half. A `tracer.barrier(n)` whose `n` is larger
than the blocks that call it raises at run end without ever releasing; one whose
`n` is smaller releases as soon as `n` arrive, so the waiting blocks resume
before the producer bound its value and the reader gets a `NameError` on a
variable with no visible connection to the barrier. Nothing in the output says
"barrier" at all.

So the `NameError` row carries that as its second cause, and the barrier row
says which way of getting the count wrong is the safe one. Mechanism lives in
`docs/usage/barrier.md`; both rows point there rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…in-place error

The attribution-patching skill's flagship validation step was a divide-by-zero.
It patched a whole layer's residual output, which makes everything downstream a
deterministic function of a clean state, so all twelve layers returned exactly
the clean metric (2.4247, std 1.17e-05). The reported r = -0.357 was the
correlation of the attribution scores with float32 rounding error, and
"top-3 by real patch: [3, 2, 10]" was topk on a twelve-way tie. The conclusion
drawn from it — that attribution and real patching disagree — was a false lesson
from a real-looking number.

Replace it with a sweep over position slices, which is non-degenerate: four
subject-token positions give r = +0.168 (real-effect spread 1.5652) and the last
position alone gives r = +0.999 (spread 1.4942). Both were already the
neighbouring table rows and both reproduce. The sweep now asserts that the real
effects have variance, so the degenerate case cannot silently return, and it is
keyed by layer rather than appended so cross-invoke ordering cannot scramble it.

`references/gradients.md` claimed `module.output[:] = new_tensor` raises
`RuntimeError: one of the variables needed for gradient computation has been
modified by an inplace operation`. It does not: replacement and in-place give
identical loss and identical gradient (5.251406192779541 both ways), and the
file's own training loop eleven lines earlier uses the supposedly-broken form.
The real footgun is position, not form — a write to a tensor the forward has
already passed is a silent no-op on a forward-only trace and an autograd error
the moment you add a backward. Both are now shown, the second under
`expect-error`.

Also adds where gradients are unavailable (inside `model.generate()`, on a frozen
model, off the metric's path), the `retain_grad()` alternative, and asserts on
the blocks that state a number.

All blocks execute: attribution-patching ran=5, nnsight/references/gradients.md
ran=12. test_structure 119 passed.
logit-lens
- The Llama-family row sent readers to model.model.layers[i].output[0]. A block
  returns a plain tensor, so that indexes the batch, and the example printed
  <|endoftext|> for every layer while the harness went green. The row, the
  example and the "returns a tuple" sentence are gone; the example now asserts
  its decoded tokens.
- Pythia unembeds through model.lm_head. model.embed_out does not exist.
- New "Check the wiring" section: lm_head(final_norm(layers[-1].output)) is
  bit-identical to model.output.logits on gpt2, SmolLM2, Qwen2.5-0.5B and
  pythia-70m, and off by 51.0 on gemma-2, which softcaps. Executed on gpt2 and
  pythia, with the softcap reapplication shown as a runnable no-op on gpt2.
- Tuned lens: keep the frozen norm and head, learn a translator. Check the norm
  before concluding the lens does not transfer - a missing ln_f gives ' the' at
  p=1.0000 on every layer, the same symptom.

attention-analysis
- attn.output[1] is the probability matrix under eager on every family tested;
  .source is for the raw pre-cast, pre-dropout softmax.
- Anchors: row sums to 1 and an exactly-zero upper triangle, asserted.
- Induction detector gains a BOS token and a non-repeating control, and asserts
  it recovers ARENA's five heads with controls under 0.01. Notes the IOI list
  disagreement instead of averaging it away.
- Sink metric excludes q=0 (A[0,0] is 1 by construction; 0.642 vs 0.615 over
  144 heads on a 14-token prompt).
- torch.no_grad() on every capture; head_dim off the module, not
  hidden // n_heads; GQA slicing stated positively; gemma-2 attention softcapping
  and nnterp's attention_probabilities added.
- The two ablation routes now assert they agree.

probing
- The two controls the skill named but never ran are code: a random direction of
  the same norm as the floor for the causal claim (probe +5.315, negated -2.950,
  eight random directions between -0.02 and +1.85 against a +1.129 baseline).
- New concept-erasure section: LEACE fitted per site, applied inside the forward
  pass, with the retrained-probe anchor, a rank-matched random control, and the
  sequential refit that is needed because a baseline-fitted eraser is the wrong
  projection downstream (0.462 at the site, 0.974 one block later; sequential
  holds every block at chance).
- no_grad memory stated as the measured pair for this capture (279 MiB against
  1293 MiB above the weights) rather than a ratio whose accounting is unstated.

Every pasted number in all three files reproduces on nnsight 0.8 /
transformers 5.15.0.dev0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sert every block

The attention-probabilities section described an accessor that failed at
construction on every family dispatching through ALL_ATTENTION_FUNCTIONS; it now
documents the accessor that drills for the interface call, with the ordering rule
it imposes (probabilities live inside the attention forward, so they come before
attentions_output), the query-head convention that makes probs[:, h] correct on a
grouped-query model, the fp16 refusal, and the layer-0 scope of load-time
validation. The stated recovery path was wrong twice over -- print_source() on a
model that construction never returned, and an operation name where an
AttnProbFunction is wanted -- and is replaced by two blocks that run.

The plain-nnsight comparison taught `model.model.layers[5].output[0]`, which on
transformers 5 indexes the sequence dimension of a block that returns a tensor
and hands back (seq, hidden) with the batch dimension gone: the wrong-but-
plausible value the page claims nnterp eliminates. The tuple story is stated as
measured -- modern decoder blocks return tensors, Bloom and GPT-J blocks return
tuples, attention modules return (output, weights) everywhere.

Every block now asserts what it claims: shapes against hidden_size/vocab_size,
next_token_probs against softmax(logits[:, -1]), rows of the attention pattern
summing to 1 with a bfloat16-sized tolerance, the causal mask, and that each
write moves the logits. The compose example compared two different prompts under
a claim of equivalence and printed False; it compares the same invoke.

Dropped: the LanguageModel deprecation-warning note, which describes a warning a
subclass never fires, and the transformers-4.57-versus-5 history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured: automatic-speech-recognition populates feature_extractor and
tokenizer, audio-classification only feature_extractor. Dropped the scan
traceback's function name, which the fake-tensor logger trims and I could not
re-confirm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill's headline table promised `fp8` raises on hardware without FP8 support.
It does not: below compute capability 8.9 transformers logs a warning, sets
`dequantize` and loads bfloat16 with the quantizer still attached. The banner
said "verified on one A100" -- an A100 is 8.0, so that row was never covered by
it. The banner now names the card the rest of this was measured on, and says
plainly that no card available here can verify `fp8` positively.

"`int8` is nearly free in accuracy" was half the story. It is at least 2.9x
bfloat16's forward, and its float16 compute dtype makes a gradient over a
large-magnitude loss entirely NaN -- which silently ends attribution patching on
an int8 model. Both are now executed blocks.

The accuracy table reported a hidden-state norm, which moves 3% at `nf4` while
the argmax under it changes on one token in eight. Replaced with KL and top-1
agreement.

"A GPU. bitsandbytes cannot quantize on CPU" is false on 0.50: `nf4` loads and
runs there. The `meta` device is the real restriction.

New in "What it will not do": MoE experts are never quantized (~3% saved),
`dtype="int3"` loads float32, and `load_in_4bit=`/`load_in_8bit=` are transformers
4 spellings whose failure names nothing the caller wrote. The Diagnosing table
gains a row for each, and the two rows describing `bytes_per_element` errors are
labelled as the server-side sizing path they come from.

Also: the `attn.shape` comment said `(1, 11, 2304)` for a 10-token GPT-2 prompt,
and "as if unquantized" for a float32 checkpoint whose activations quantizing
halves. Every block now asserts.

`references/modules-and-architectures.md` listed `torch_dtype=` and no
quantization at all, so an nnsight-skill agent hitting an OOM had no pointer here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`max_tokens` is a cap, not a promise, and a loop the run cannot supply is now an
error naming what it asked for and what the run made. The remedies are
`ignore_eos=True` or `min_tokens=N` (the error text names `min_new_tokens=`,
which this path refuses), or `tracer.all()` with the trailing statements after
the `with` block. An open loop still warns, from the EngineCore subprocess where
`warnings.catch_warnings` cannot see it, which is the only behaviour left that
differs from the local path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bound that stops short of the run's last step completes clean while writing the
wrong steps, so the catalogue and the skill both say that a loop body's ordering
bug is not something an exception can be relied on to report — check the writes
against a per-step baseline.

The over-run message is re-quoted from source: vLLM spells the minimum
`min_tokens=`/`ignore_eos=True`, no minimum survives a stop string, and trailing
code belongs in a separate `tracer.invoke()` rather than after the `with`, where
`tracer.result` cannot be read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
model-steering
- The alpha=0.25 row of the coefficient sweep never ran. Steering makes GPT-2
  emit EOS after 11 of 12 forward passes, the bounded `tracer.iter[:12]` loop
  never completed, `ids = tracer.result.save()` was skipped, and the sweep
  re-printed the previous alpha's generation. "Nothing happens at 0.25" was a
  stale variable. `min_new_tokens=12` fixes it; alpha=0.25 gives
  'released in the UK', not 'released in Japan'. The sweep now asserts that no
  two rows are identical.
- Added a repeated-bigram column as the collapse signal, and replaced the
  next-token-entropy fluency proxy, which is non-monotonic (5.43 at alpha 0,
  5.95 at alpha 2.0 where the output is a loop), with p(unsteered top token),
  which falls monotonically 0.088 -> 0.0004 and is asserted to.
- Function vectors: extended to seven countries with a matched-norm random
  control. Three hits; the control changes three of seven outputs. Added the
  few-shot control as a runnable block: five of seven with demonstrations, and
  `Portugal`, the row the published example showed, is one of the two failures.
- The opposite-direction check is now run on the skill's own vector, which
  fails it.
- `tracer.iter[1:]` -> `tracer.iter[1:12]`, with the reason.

sae-and-dictionary-learning
- L0 is compared against `d_model`, with the rotated-identity null as a runnable
  block: explained variance 1.000, L0 768 of 768, and on 8,192 held-out GPT-2
  activations also 0 dead, cosine 1.000, dCE -0.000, loss recovered 1.0000.
  Every reported metric except L0 comes back perfect on data it never saw.
- Metrics are computed on a held-out split. Added the memorised-dictionary
  result (8% dead on the fitting set, 69% held out) as the reason.
- Collection masks pad positions: 29 of 112 vectors were pad, and a GPT-2 pad
  residual carries 8.3x the norm of a real one.
- Max-activating examples mask before ranking. 327 of 1,678 live features have
  their unmasked argmax on a pad token, where the left-padding correction goes
  negative and a `max(0, position)` guard prints token 0 as the answer.
- Feature steering was a silent null (base and steered both ' released'). It now
  picks the feature by max activation and sweeps, and says outright that the
  dictionary is too degenerate for the tokens to mean anything.
- "104 activation vectors" -> the 83 the block actually collects.
- The unconditioned "3.6x peak memory" is now the measured pair (1133 MiB
  against 242 MiB, 64 sequences) with the note that the multiple is workload
  dependent.
- Added the observe-without-splicing edit, which leaves the model's answer
  unchanged while making `sae.encoder.output` readable.

Every block in both files runs and reproduces its published output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge the two dose-response bullets, and name both few-shot failures
(Brazil answers with Argentina's capital) so the five-of-seven count reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule is stated once at collection; feature analysis points back to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dence

causal-tracing: move the ROME protocol to Qwen3-8B, where the classic two-site
structure reproduces from single-state restoration (subject site, layers 6-15,
recovery 1.16; last-token site, layers 24-35, recovery 1.03), averaged over ten
noise seeds. Add noise calibration, since 3-sigma of the embedding matrix leaves
one seed in ten at P=0.93 on this model. Replace the windowed-residual table,
which is a single-layer restore relabelled at one position, with severed MLP and
attention traces that split the two sites. Record the GPT-2-small measurement
that motivates the move: the same protocol there tops out at 3.4% recovery at the
subject with no peak.

circuit-discovery: scale faithfulness against the reachable floor. Mean-ablating
all 144 heads holds the metric at +0.785, so the old denominator inflated every
score by 66 points and the ten worst heads scored 85%. On the new scale the
circuit is 96%, the random control -2%, and a deliberately wrong circuit of ten
layer-0-2 heads is -0.1%, asserted. Prune against the floor (three heads, 93%)
and read completeness as 41% of achievable damage.

activation-patching: delete the layer-11 "sanity check", which passes for a
1000x-noise donor. Replace with a self-patch that must not move the metric and a
matched-norm random donor that swings it +3.6. Reframe the layer sweep as
reachability, print the layer x position map that actually localizes, seed the
DAS direction, and add the arbitrary-target null to the DAS controls.

interp-experiment-design: a constant added to a residual stream is removed by the
next LayerNorm (+10000 moves log P by <1e-3), so extreme-value checks need a real
tensor. Add the empty-circuit floor to the controls table.

ablation: state why the ragged mean-ablation corpus is safe (left padding plus
[:, -1, :]) and what breaks it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every invoke's activations are padded to the batch's longest prompt, so an
absolute position slice means something different depending on what else is in
the batch. Every sweep in the activation-patching skill batches one prompt pair
at one length, which is what makes slice(1, 5) and the appended result order safe
there; say so where a reader would otherwise generalise it.

Also name the empty-circuit floor in circuit-discovery's description, since that
is now what the skill is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JadenFiotto-Kaufman and others added 23 commits September 3, 2026 00:48
Also: trailing code that needs tracer.result goes in a separate invoke, not
after the with block, where the result is unreadable.
…stead

An over-running tracer.iter loop warns and is cut short (blocks now
assert the short length, the unbound tail, and the warning text instead
of expecting OutOfOrderError); the batched-write row rule is stated as
an unchecked constraint, and the vLLM wrong-rows refusal demo is
replaced by the plain warning that a wrong-height write can take the
engine down; the error catalogue and triage tables route the symptoms
(short result, late shape error) instead of the removed messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cold review of PR #7 found three rewritten sections that still print
tables without asserting what the prose claims, plus two garbled
sentences:

- model-steering function vectors (finding 2, MAJOR): both blocks now
  collect their decodes and assert the counts and named rows — the
  vector hits exactly {Portugal, Austria, Thailand}, exactly
  {Poland, Greece, Sweden} echo the country name, the random control
  moves exactly 3/7 outputs, the few-shot control fails exactly
  {Portugal, Brazil} (' Rome' / ' Buenos'), and the Portugal
  coincidence (an FV hit on a few-shot failure) is asserted directly.
- activation-patching layer x position map (finding 3): the subject
  assert now splits early (L0-L4, max crosses zero) from late (L8-L10,
  none does), and the suffix band and ' of' column are pinned flat at
  baseline within 0.15 through L6.
- attribution-patching validate (finding 4): r_last > r_subject becomes
  r_last > 0.99 and |r_subject| < 0.5, pinning the +0.999 / +0.168
  headline instead of a bare ordering.
- error-catalogue (finding 6): "no warning and no warning" -> "no
  warning and no error".
- vllm SKILL (finding 7): the self-contradicting run-on about
  engine-side warnings vs build failures rewritten and rewrapped;
  no behavior claims changed.

All strengthened blocks re-run green locally (model-steering 7/7,
activation-patching 9/9, attribution-patching 5/5 blocks ran);
test_structure 119 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fully-cut path fails loudly; a bypassed cut silently drops that
module's contribution from every upstream gradient — measured 45%
relative error on a bit-identical forward. From the cold review of the
website PR.
nnterp does only the label read now: no load-time cross-check, no fp16
refusal (NaN is back to being the user's to check — validation passes
vacuously on it), and check_renaming=False disables the accessor. The
module's returned weights stay documented as a label-free second reading
to sanity-check against.
@JadenFiotto-Kaufman
JadenFiotto-Kaufman merged commit c2c8d35 into 0.8 Sep 3, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant