brainpatch 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. brainpatch/__init__.py +92 -0
  2. brainpatch/backends/__init__.py +19 -0
  3. brainpatch/backends/llamacpp.py +383 -0
  4. brainpatch/backends/mlx_backend.py +213 -0
  5. brainpatch/backends/transformers_backend.py +473 -0
  6. brainpatch/backends/vllm_backend.py +299 -0
  7. brainpatch/backends/vllm_worker.py +129 -0
  8. brainpatch/cli.py +825 -0
  9. brainpatch/config.py +245 -0
  10. brainpatch/datasets/__init__.py +20 -0
  11. brainpatch/datasets/contrast_sets.py +64 -0
  12. brainpatch/evaluation/__init__.py +28 -0
  13. brainpatch/evaluation/metrics.py +223 -0
  14. brainpatch/patch/__init__.py +64 -0
  15. brainpatch/patch/compiler.py +324 -0
  16. brainpatch/patch/format.py +489 -0
  17. brainpatch/patch/loader.py +312 -0
  18. brainpatch/patch/registry.py +300 -0
  19. brainpatch/patch/tensors.py +236 -0
  20. brainpatch/patch/validation.py +157 -0
  21. brainpatch/paths.py +184 -0
  22. brainpatch/py.typed +0 -0
  23. brainpatch/research/__init__.py +16 -0
  24. brainpatch/research/antisycophancy.py +348 -0
  25. brainpatch/research/behaviour_eval.py +711 -0
  26. brainpatch/research/generation_eval.py +346 -0
  27. brainpatch/research/ml/__init__.py +35 -0
  28. brainpatch/research/ml/activation_store.py +232 -0
  29. brainpatch/research/ml/causal.py +386 -0
  30. brainpatch/research/ml/corpus.py +165 -0
  31. brainpatch/research/ml/evaluation.py +188 -0
  32. brainpatch/research/ml/extraction.py +464 -0
  33. brainpatch/research/ml/feature_analysis.py +317 -0
  34. brainpatch/research/ml/generation.py +109 -0
  35. brainpatch/research/ml/hooks.py +183 -0
  36. brainpatch/research/ml/intervention.py +274 -0
  37. brainpatch/research/ml/model.py +219 -0
  38. brainpatch/research/ml/patch_search.py +337 -0
  39. brainpatch/research/ml/runtime.py +343 -0
  40. brainpatch/research/ml/sae.py +383 -0
  41. brainpatch/research/ml/training.py +376 -0
  42. brainpatch/research/stance_rubric.py +170 -0
  43. brainpatch/research/sycophancy_data.py +982 -0
  44. brainpatch/research/sycophancy_data_r1.py +1701 -0
  45. brainpatch/research/sycophancy_data_v2.py +1649 -0
  46. brainpatch/research/sycophancy_data_v3.py +2288 -0
  47. brainpatch/research/sycophancy_v2_build.py +362 -0
  48. brainpatch/research/sycophancy_v3_build.py +188 -0
  49. brainpatch/research/utility_probe.py +139 -0
  50. brainpatch/runtime/__init__.py +50 -0
  51. brainpatch/runtime/auto.py +157 -0
  52. brainpatch/runtime/base.py +311 -0
  53. brainpatch/runtime/capabilities.py +96 -0
  54. brainpatch/runtime/model.py +260 -0
  55. brainpatch/runtime/scheduling.py +13 -0
  56. brainpatch/schemas/__init__.py +35 -0
  57. brainpatch/schemas/contrast.py +161 -0
  58. brainpatch/schemas/feature.py +193 -0
  59. brainpatch/schemas/manifest.py +167 -0
  60. brainpatch/schemas/patch.py +379 -0
  61. brainpatch/schemas/patch_io.py +88 -0
  62. brainpatch/schemas/sae.py +146 -0
  63. brainpatch/server/__init__.py +11 -0
  64. brainpatch/server/app.py +269 -0
  65. brainpatch/steering/__init__.py +13 -0
  66. brainpatch/steering/plan.py +177 -0
  67. brainpatch/steering/schedule.py +138 -0
  68. brainpatch/ui/__init__.py +11 -0
  69. brainpatch/ui/app.py +201 -0
  70. brainpatch/verify/__init__.py +66 -0
  71. brainpatch/verify/behavioural.py +156 -0
  72. brainpatch/verify/checks.py +204 -0
  73. brainpatch/verify/corruptions.py +335 -0
  74. brainpatch/verify/report.py +133 -0
  75. brainpatch/verify/vectors.py +95 -0
  76. brainpatch/verify/workflow.py +331 -0
  77. brainpatch-1.2.0.dist-info/METADATA +556 -0
  78. brainpatch-1.2.0.dist-info/RECORD +82 -0
  79. brainpatch-1.2.0.dist-info/WHEEL +5 -0
  80. brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
  81. brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
  82. brainpatch-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,317 @@
1
+ """Building the feature database from a trained SAE.
2
+
3
+ For every dictionary feature this computes firing statistics over the
4
+ activation corpus and recovers the token contexts that drive it hardest.
5
+
6
+ What this module deliberately does **not** do is assign a semantic label. Top
7
+ activating contexts are correlational evidence. A feature whose top examples are
8
+ all hedging language is a feature that *correlates with* hedging language in
9
+ this corpus -- it is not "the uncertainty feature" until steering it changes
10
+ behaviour and scale-matched controls do not. Every record leaves
11
+ ``hypothesis=None`` and ``evidence_level="none"``; the causal-validation
12
+ pipeline is the only thing that writes anything stronger.
13
+
14
+ Runs on CPU. A 2048-feature dictionary over 20k activations is a couple of
15
+ matrix multiplies, and CPU Modal Functions cost a fraction of GPU ones.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import torch
25
+
26
+ from brainpatch.research.ml.activation_store import ActivationSubset, read_manifest
27
+ from brainpatch.research.ml.sae import TopKSAE
28
+ from brainpatch.paths import VolumePaths
29
+ from brainpatch.schemas.feature import FeatureContext, FeatureRecord, FeatureStats
30
+
31
+
32
+ def load_examples(paths: VolumePaths, experiment: str) -> dict[int, dict[str, Any]]:
33
+ """Load ``examples.jsonl`` into an index -> row mapping."""
34
+ path = Path(paths.activation_examples(experiment))
35
+ if not path.is_file():
36
+ raise FileNotFoundError(f"examples file not found: {path}")
37
+ examples: dict[int, dict[str, Any]] = {}
38
+ with path.open(encoding="utf-8") as handle:
39
+ for line in handle:
40
+ line = line.strip()
41
+ if not line:
42
+ continue
43
+ row = json.loads(line)
44
+ examples[int(row["index"])] = row
45
+ return examples
46
+
47
+
48
+ @torch.no_grad()
49
+ def compute_feature_activations(
50
+ sae: TopKSAE,
51
+ subset: ActivationSubset,
52
+ *,
53
+ input_scale: float,
54
+ batch_size: int = 4096,
55
+ ) -> torch.Tensor:
56
+ """Encode the whole corpus into a ``[tokens, d_sae]`` sparse activation matrix.
57
+
58
+ Kept dense in float32 for simplicity: at smoke scale that is
59
+ ``20k x 2048 x 4B = 164 MB``. For a serious run this should stream and
60
+ accumulate statistics incrementally instead; the ``max_bytes`` guard on
61
+ :class:`ActivationSubset` is what stops that limit being crossed silently.
62
+ """
63
+ sae.eval()
64
+ chunks: list[torch.Tensor] = []
65
+ for start in range(0, len(subset), batch_size):
66
+ batch = subset.activations[start : start + batch_size].to(torch.float32) * input_scale
67
+ sparse, _, _ = sae.encode(batch)
68
+ chunks.append(sparse.cpu())
69
+ return torch.cat(chunks, dim=0)
70
+
71
+
72
+ def build_feature_database(
73
+ sae: TopKSAE,
74
+ subset: ActivationSubset,
75
+ paths: VolumePaths,
76
+ experiment: str,
77
+ *,
78
+ input_scale: float,
79
+ top_k_contexts: int = 8,
80
+ context_window: int = 12,
81
+ tokenizer: Any = None,
82
+ ) -> dict[str, Any]:
83
+ """Compute per-feature statistics and top contexts, and persist them.
84
+
85
+ Returns a summary dict; the per-feature records go to
86
+ ``/vol/feature-db/<experiment>/features.jsonl``.
87
+ """
88
+ manifest = read_manifest(paths, experiment)
89
+ examples = load_examples(paths, experiment)
90
+ acts = compute_feature_activations(sae, subset, input_scale=input_scale)
91
+ n_tokens, d_sae = acts.shape
92
+
93
+ fire_mask = acts > 0
94
+ fire_count = fire_mask.sum(dim=0)
95
+ act_sum = acts.sum(dim=0)
96
+ max_act = acts.max(dim=0).values
97
+ decoder_norms = sae.decoder_norms().cpu()
98
+
99
+ # Mean/std over *firing* tokens only: averaging in the structural zeros of a
100
+ # Top-K SAE would just report k/d_sae times the true magnitude.
101
+ mean_act = torch.where(fire_count > 0, act_sum / fire_count.clamp_min(1), torch.zeros_like(act_sum))
102
+ sq_sum = (acts.pow(2)).sum(dim=0)
103
+ var = torch.where(
104
+ fire_count > 0,
105
+ (sq_sum / fire_count.clamp_min(1)) - mean_act.pow(2),
106
+ torch.zeros_like(act_sum),
107
+ ).clamp_min(0.0)
108
+ std_act = var.sqrt()
109
+
110
+ out_dir = Path(paths.feature_db(experiment))
111
+ out_dir.mkdir(parents=True, exist_ok=True)
112
+ features_path = Path(paths.features_jsonl(experiment))
113
+
114
+ alive = 0
115
+ with features_path.open("w", encoding="utf-8") as handle:
116
+ for feature_id in range(d_sae):
117
+ count = int(fire_count[feature_id].item())
118
+ stats = FeatureStats(
119
+ fire_count=count,
120
+ total_tokens=n_tokens,
121
+ mean_activation=float(mean_act[feature_id].item()),
122
+ max_activation=float(max_act[feature_id].item()),
123
+ std_activation=float(std_act[feature_id].item()),
124
+ decoder_norm=float(decoder_norms[feature_id].item()),
125
+ )
126
+ contexts: list[FeatureContext] = []
127
+ if count > 0:
128
+ alive += 1
129
+ contexts = _top_contexts(
130
+ acts[:, feature_id],
131
+ subset.meta,
132
+ examples,
133
+ manifest.sequence_length,
134
+ top_k=top_k_contexts,
135
+ window=context_window,
136
+ tokenizer=tokenizer,
137
+ )
138
+ # hypothesis stays None and evidence_level stays "none" by design.
139
+ record = FeatureRecord(feature_id=feature_id, stats=stats, top_contexts=contexts)
140
+ handle.write(record.to_json() + "\n")
141
+
142
+ firing_rates = (fire_count.float() / n_tokens).tolist()
143
+ alive_rates = [r for r in firing_rates if r > 0]
144
+ summary = {
145
+ "experiment": experiment,
146
+ "num_features": d_sae,
147
+ "num_tokens_analysed": n_tokens,
148
+ "alive_features": alive,
149
+ "dead_features": d_sae - alive,
150
+ "dead_fraction": (d_sae - alive) / d_sae,
151
+ "mean_firing_rate_alive": (sum(alive_rates) / len(alive_rates)) if alive_rates else 0.0,
152
+ "median_firing_rate_alive": _median(alive_rates),
153
+ "max_firing_rate": max(firing_rates) if firing_rates else 0.0,
154
+ "mean_l0": float(fire_mask.sum(dim=1).float().mean().item()),
155
+ "decoder_norm_mean": float(decoder_norms.mean().item()),
156
+ "decoder_norm_min": float(decoder_norms.min().item()),
157
+ "decoder_norm_max": float(decoder_norms.max().item()),
158
+ "input_scale": input_scale,
159
+ "features_path": str(features_path),
160
+ "note": (
161
+ "Statistics only. No feature carries a semantic label: top-activating "
162
+ "contexts are correlational evidence and are not sufficient to name a feature."
163
+ ),
164
+ }
165
+ Path(paths.feature_summary(experiment)).write_text(
166
+ json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8"
167
+ )
168
+ return summary
169
+
170
+
171
+ def _top_contexts(
172
+ feature_column: torch.Tensor,
173
+ meta: torch.Tensor,
174
+ examples: dict[int, dict[str, Any]],
175
+ sequence_length: int,
176
+ *,
177
+ top_k: int,
178
+ window: int,
179
+ tokenizer: Any = None,
180
+ ) -> list[FeatureContext]:
181
+ """Recover the highest-activating token occurrences with surrounding text."""
182
+ nonzero = (feature_column > 0).nonzero(as_tuple=True)[0]
183
+ if nonzero.numel() == 0:
184
+ return []
185
+ k = min(top_k, nonzero.numel())
186
+ values = feature_column[nonzero]
187
+ order = torch.topk(values, k).indices
188
+ rows = nonzero[order]
189
+
190
+ contexts: list[FeatureContext] = []
191
+ for row in rows.tolist():
192
+ example_index = int(meta[row, 0].item())
193
+ position = int(meta[row, 1].item())
194
+ token_id = int(meta[row, 2].item())
195
+ example = examples.get(example_index)
196
+
197
+ token_text = ""
198
+ before = after = ""
199
+ if tokenizer is not None:
200
+ token_text = tokenizer.decode([token_id])
201
+ if example is not None:
202
+ before, after = _decode_window(
203
+ tokenizer, example.get("text", ""), position, window
204
+ )
205
+ elif example is not None:
206
+ before, after = "", example.get("text", "")[:200]
207
+
208
+ contexts.append(
209
+ FeatureContext(
210
+ example_index=example_index,
211
+ token_position=position,
212
+ token_id=token_id,
213
+ token_text=token_text,
214
+ activation=float(feature_column[row].item()),
215
+ context_before=before,
216
+ context_after=after,
217
+ )
218
+ )
219
+ return contexts
220
+
221
+
222
+ def _decode_window(tokenizer: Any, text: str, position: int, window: int) -> tuple[str, str]:
223
+ """Re-tokenize the stored text and slice a window around ``position``.
224
+
225
+ Re-tokenizing is cheap and avoids storing per-token strings for the whole
226
+ corpus, which would dominate the on-disk size.
227
+ """
228
+ ids = tokenizer(text, add_special_tokens=False)["input_ids"]
229
+ if position >= len(ids):
230
+ return "", ""
231
+ lo = max(0, position - window)
232
+ hi = min(len(ids), position + window + 1)
233
+ return tokenizer.decode(ids[lo:position]), tokenizer.decode(ids[position + 1 : hi])
234
+
235
+
236
+ def rank_features(
237
+ paths: VolumePaths,
238
+ experiment: str,
239
+ *,
240
+ by: str = "max_activation",
241
+ limit: int = 50,
242
+ min_fire_count: int = 1,
243
+ max_firing_rate: float = 1.0,
244
+ min_firing_rate: float = 0.0,
245
+ ) -> list[FeatureRecord]:
246
+ """Rank features from the persisted database.
247
+
248
+ Parameters
249
+ ----------
250
+ by:
251
+ ``max_activation``, ``mean_activation``, ``fire_count`` or ``firing_rate``.
252
+ max_firing_rate:
253
+ Drop features that fire on more than this fraction of tokens. Very
254
+ high-frequency features are usually modelling something positional or
255
+ distributional rather than anything specific.
256
+ min_firing_rate:
257
+ Drop features that fire on *fewer* than this fraction of tokens. See the
258
+ warning below -- this is the guard that matters in practice.
259
+
260
+ Warning
261
+ -------
262
+ **Ranking by ``max_activation`` alone selects outliers, and did so
263
+ destructively in ``smoke_v0``.** Measured on that feature database: the top
264
+ 32 features by ``max_activation`` all fired on 3-6 tokens out of 20,000, all
265
+ with the same top token (``" Bd"``, chess notation from a handful of
266
+ wikitext articles), at activations 100x+ the dictionary median of 9.06. An
267
+ undertrained SAE shatters rare high-norm tokens across many near-duplicate
268
+ features, and this ranking finds precisely those.
269
+
270
+ The consequence in ``smoke_v0`` was worse than a poor choice of target: the
271
+ "unrelated feature" control was drawn from the same ranking and landed on
272
+ feature 1270, a near-duplicate of the target firing on the same token. That
273
+ control was therefore not unrelated and its comparison is uninformative.
274
+
275
+ For intervention candidates, pass ``min_firing_rate`` at or near the
276
+ dictionary median firing rate, or rank by ``mean_activation`` /
277
+ ``fire_count`` instead.
278
+ """
279
+ path = Path(paths.features_jsonl(experiment))
280
+ if not path.is_file():
281
+ raise FileNotFoundError(f"feature database not found: {path}")
282
+
283
+ records: list[FeatureRecord] = []
284
+ with path.open(encoding="utf-8") as handle:
285
+ for line in handle:
286
+ line = line.strip()
287
+ if not line:
288
+ continue
289
+ record = FeatureRecord.from_dict(json.loads(line))
290
+ if record.stats.fire_count < min_fire_count:
291
+ continue
292
+ if record.stats.firing_rate > max_firing_rate:
293
+ continue
294
+ if record.stats.firing_rate < min_firing_rate:
295
+ continue
296
+ records.append(record)
297
+
298
+ keys = {
299
+ "max_activation": lambda r: r.stats.max_activation,
300
+ "mean_activation": lambda r: r.stats.mean_activation,
301
+ "fire_count": lambda r: r.stats.fire_count,
302
+ "firing_rate": lambda r: r.stats.firing_rate,
303
+ }
304
+ if by not in keys:
305
+ raise ValueError(f"unknown ranking key {by!r}; expected one of {sorted(keys)}")
306
+ records.sort(key=keys[by], reverse=True)
307
+ return records[:limit]
308
+
309
+
310
+ def _median(values: list[float]) -> float:
311
+ if not values:
312
+ return 0.0
313
+ ordered = sorted(values)
314
+ mid = len(ordered) // 2
315
+ if len(ordered) % 2:
316
+ return ordered[mid]
317
+ return (ordered[mid - 1] + ordered[mid]) / 2
@@ -0,0 +1,109 @@
1
+ """Deterministic generation and log-probability measurement.
2
+
3
+ Every comparison in the causal-validation harness -- baseline, intervention,
4
+ control -- must differ *only* in the intervention. That means identical
5
+ sampling settings, identical seeds, and identical prompts.
6
+ :class:`GenerationConfig` makes those settings one object that gets passed to
7
+ every condition, so there is no way to accidentally give the baseline a
8
+ different temperature.
9
+
10
+ Greedy decoding is the default. Sampling adds variance that would have to be
11
+ averaged out with many more (paid) generations before an effect could be
12
+ distinguished from noise.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import asdict, dataclass
18
+ from typing import Any
19
+
20
+ import torch
21
+
22
+
23
+ @dataclass
24
+ class GenerationConfig:
25
+ """Sampling settings, shared identically across all compared conditions."""
26
+
27
+ max_new_tokens: int = 128
28
+ do_sample: bool = False
29
+ temperature: float = 1.0
30
+ top_p: float = 1.0
31
+ top_k: int = 0
32
+ repetition_penalty: float = 1.0
33
+ seed: int = 0
34
+
35
+ def to_kwargs(self, tokenizer: Any) -> dict[str, Any]:
36
+ kwargs: dict[str, Any] = {
37
+ "max_new_tokens": self.max_new_tokens,
38
+ "do_sample": self.do_sample,
39
+ "pad_token_id": tokenizer.pad_token_id or tokenizer.eos_token_id,
40
+ }
41
+ if self.do_sample:
42
+ kwargs["temperature"] = self.temperature
43
+ kwargs["top_p"] = self.top_p
44
+ if self.top_k > 0:
45
+ kwargs["top_k"] = self.top_k
46
+ if self.repetition_penalty != 1.0:
47
+ kwargs["repetition_penalty"] = self.repetition_penalty
48
+ return kwargs
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return asdict(self)
52
+
53
+
54
+ def build_chat_prompt(tokenizer: Any, user_message: str, system: str | None = None) -> str:
55
+ """Render a user message through the model's chat template.
56
+
57
+ Falls back to the raw message for base models with no template, rather than
58
+ silently inventing one -- a wrong template changes the distribution enough
59
+ to invalidate a comparison.
60
+ """
61
+ if getattr(tokenizer, "chat_template", None) is None:
62
+ return user_message
63
+ messages: list[dict[str, str]] = []
64
+ if system:
65
+ messages.append({"role": "system", "content": system})
66
+ messages.append({"role": "user", "content": user_message})
67
+ return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
68
+
69
+
70
+ @torch.inference_mode()
71
+ def sequence_logprob(
72
+ model: Any,
73
+ tokenizer: Any,
74
+ prompt: str,
75
+ continuation: str,
76
+ device: torch.device | str = "cuda",
77
+ ) -> dict[str, float]:
78
+ """Log-probability the model assigns to ``continuation`` after ``prompt``.
79
+
80
+ Used to measure an intervention's effect without generating: the difference
81
+ in logprob between a positive and negative response under baseline versus
82
+ steered conditions is a lower-variance signal than comparing free
83
+ generations.
84
+
85
+ Returns total and per-token log-probability, plus the token count.
86
+ """
87
+ prompt_ids = tokenizer(prompt, return_tensors="pt", add_special_tokens=True).input_ids
88
+ full_ids = tokenizer(
89
+ prompt + continuation, return_tensors="pt", add_special_tokens=True
90
+ ).input_ids
91
+ prompt_len = prompt_ids.shape[1]
92
+ n_continuation = full_ids.shape[1] - prompt_len
93
+ if n_continuation <= 0:
94
+ return {"total_logprob": 0.0, "mean_logprob": 0.0, "num_tokens": 0}
95
+
96
+ full_ids = full_ids.to(device)
97
+ logits = model(input_ids=full_ids).logits.float()
98
+ log_probs = torch.log_softmax(logits[:, :-1, :], dim=-1)
99
+ targets = full_ids[:, 1:]
100
+
101
+ # Score only the continuation positions.
102
+ start = prompt_len - 1
103
+ selected = log_probs[0, start:, :].gather(-1, targets[0, start:].unsqueeze(-1)).squeeze(-1)
104
+ total = float(selected.sum().item())
105
+ return {
106
+ "total_logprob": total,
107
+ "mean_logprob": total / selected.numel(),
108
+ "num_tokens": int(selected.numel()),
109
+ }
@@ -0,0 +1,183 @@
1
+ """Residual-stream capture and injection hooks.
2
+
3
+ Both directions of the pipeline attach to the *output* of a decoder block:
4
+
5
+ * :class:`ResidualCapture` records what the block wrote, which is what the SAE
6
+ is trained on.
7
+ * :class:`ResidualInjector` adds a vector to the same tensor, which is how a
8
+ BrainPatch takes effect.
9
+
10
+ Using the identical site for both is not a convenience -- it is a correctness
11
+ requirement. An SAE decoder direction is only meaningful in the coordinate
12
+ system it was fitted in, so injecting at any other site would be adding a
13
+ vector that means nothing there.
14
+
15
+ Decoder blocks in transformers return either a bare tensor or a tuple whose
16
+ first element is the hidden state, depending on version and config. Both shapes
17
+ are handled, and the tuple is rebuilt rather than mutated so that nothing
18
+ downstream sees a half-modified structure.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Callable
24
+
25
+ import torch
26
+
27
+
28
+ def _split_output(output: Any) -> tuple[torch.Tensor, Callable[[torch.Tensor], Any]]:
29
+ """Extract the hidden-state tensor and a rebuilder for the block output.
30
+
31
+ Returns
32
+ -------
33
+ (hidden_states, rebuild)
34
+ ``rebuild(new_tensor)`` reconstructs the original container type with
35
+ the tensor replaced.
36
+ """
37
+ if isinstance(output, torch.Tensor):
38
+ return output, lambda t: t
39
+ if isinstance(output, tuple):
40
+ if not output or not isinstance(output[0], torch.Tensor):
41
+ raise TypeError(f"unexpected decoder block output tuple: {type(output)}")
42
+ rest = output[1:]
43
+ return output[0], lambda t: (t, *rest)
44
+ # Some versions return a ModelOutput-like object with .last_hidden_state.
45
+ hidden = getattr(output, "last_hidden_state", None)
46
+ if isinstance(hidden, torch.Tensor):
47
+
48
+ def rebuild(t: torch.Tensor, _out: Any = output) -> Any:
49
+ _out.last_hidden_state = t
50
+ return _out
51
+
52
+ return hidden, rebuild
53
+ raise TypeError(f"cannot locate hidden states in decoder block output of type {type(output)}")
54
+
55
+
56
+ class ResidualCapture:
57
+ """Capture the residual stream at one decoder block.
58
+
59
+ Usage::
60
+
61
+ capture = ResidualCapture()
62
+ handle = capture.attach(bundle.layer_module(18))
63
+ with torch.inference_mode():
64
+ model(**batch)
65
+ acts = capture.activations # [batch, seq, hidden]
66
+ handle.remove()
67
+
68
+ The captured tensor is *detached* and optionally moved off-GPU immediately,
69
+ so a long extraction run does not accumulate VRAM.
70
+ """
71
+
72
+ def __init__(self, *, to_cpu: bool = True, dtype: torch.dtype | None = None) -> None:
73
+ self.to_cpu = to_cpu
74
+ self.dtype = dtype
75
+ self.activations: torch.Tensor | None = None
76
+ self._handle: Any = None
77
+
78
+ def __call__(self, module: Any, args: Any, output: Any) -> None:
79
+ hidden, _ = _split_output(output)
80
+ tensor = hidden.detach()
81
+ if self.dtype is not None:
82
+ tensor = tensor.to(self.dtype)
83
+ if self.to_cpu:
84
+ tensor = tensor.to("cpu")
85
+ self.activations = tensor
86
+
87
+ def attach(self, module: Any) -> Any:
88
+ """Register on ``module`` and return the removable handle."""
89
+ self._handle = module.register_forward_hook(self)
90
+ return self._handle
91
+
92
+ def remove(self) -> None:
93
+ if self._handle is not None:
94
+ self._handle.remove()
95
+ self._handle = None
96
+
97
+ def __enter__(self) -> "ResidualCapture":
98
+ return self
99
+
100
+ def __exit__(self, *exc: Any) -> None:
101
+ self.remove()
102
+
103
+
104
+ class ResidualInjector:
105
+ """Add a per-token vector to the residual stream at one decoder block.
106
+
107
+ The injected vector is supplied by a callback rather than fixed at
108
+ construction, because in dynamic steering it changes with the generated
109
+ token index. The callback receives the current hidden states and returns
110
+ either a broadcastable tensor to add, or ``None`` for "do nothing".
111
+
112
+ Returning ``None`` is the mechanism that makes ``strength=0`` *identical*
113
+ to baseline rather than approximately equal: no arithmetic is performed on
114
+ the tensor at all, so there is not even a float round-trip to differ on.
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ delta_fn: Callable[[torch.Tensor], torch.Tensor | None],
120
+ *,
121
+ name: str = "injector",
122
+ ) -> None:
123
+ self.delta_fn = delta_fn
124
+ self.name = name
125
+ self._handle: Any = None
126
+ #: Incremented every time a non-None delta is actually applied.
127
+ self.apply_count = 0
128
+ #: Incremented on every forward pass through the hooked module.
129
+ self.call_count = 0
130
+
131
+ def __call__(self, module: Any, args: Any, output: Any) -> Any:
132
+ self.call_count += 1
133
+ hidden, rebuild = _split_output(output)
134
+ delta = self.delta_fn(hidden)
135
+ if delta is None:
136
+ # Untouched: bit-identical to running without the hook.
137
+ return output
138
+ self.apply_count += 1
139
+ modified = hidden + delta.to(dtype=hidden.dtype, device=hidden.device)
140
+ return rebuild(modified)
141
+
142
+ def attach(self, module: Any) -> Any:
143
+ self._handle = module.register_forward_hook(self)
144
+ return self._handle
145
+
146
+ def remove(self) -> None:
147
+ if self._handle is not None:
148
+ self._handle.remove()
149
+ self._handle = None
150
+
151
+ def __enter__(self) -> "ResidualInjector":
152
+ return self
153
+
154
+ def __exit__(self, *exc: Any) -> None:
155
+ self.remove()
156
+
157
+
158
+ class HookSet:
159
+ """Context manager owning several hook handles at once.
160
+
161
+ Guarantees removal even if the forward pass raises, which matters because a
162
+ leaked injection hook would silently contaminate every subsequent
163
+ generation in the same process -- including the "baseline" ones.
164
+ """
165
+
166
+ def __init__(self) -> None:
167
+ self._hooks: list[Any] = []
168
+
169
+ def add(self, hook: ResidualCapture | ResidualInjector, module: Any) -> Any:
170
+ handle = hook.attach(module)
171
+ self._hooks.append(hook)
172
+ return handle
173
+
174
+ def remove_all(self) -> None:
175
+ for hook in self._hooks:
176
+ hook.remove()
177
+ self._hooks.clear()
178
+
179
+ def __enter__(self) -> "HookSet":
180
+ return self
181
+
182
+ def __exit__(self, *exc: Any) -> None:
183
+ self.remove_all()