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,337 @@
1
+ """Candidate-feature search from behavioural contrast data.
2
+
3
+ Pipeline::
4
+
5
+ contrast pairs -> activations for positive/negative responses
6
+ -> SAE encode
7
+ -> per-feature mean activation difference
8
+ -> ranked candidates (correlational)
9
+ -> causal test with controls (interventional)
10
+ -> strength sweep
11
+ -> greedy sparse combination
12
+ -> held-out evaluation
13
+
14
+ Only the first three steps are cheap. Everything after that costs GPU time per
15
+ candidate, which is why :func:`rank_candidate_features` returns a *small*
16
+ shortlist and the causal stages take an explicit budget. A search that fans out
17
+ over hundreds of candidates is how a $10 budget disappears.
18
+
19
+ The ranking step produces correlational evidence only. A feature that activates
20
+ more on "independent criticism" continuations than on "agreeable" ones is a
21
+ feature that *tracks* that distinction in this fixture -- it is not an
22
+ anti-sycophancy control knob until an intervention says so.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Sequence
29
+
30
+ import torch
31
+
32
+ from brainpatch.research.ml.generation import build_chat_prompt
33
+ from brainpatch.research.ml.hooks import ResidualCapture
34
+ from brainpatch.research.ml.model import ModelBundle
35
+ from brainpatch.research.ml.sae import TopKSAE
36
+ from brainpatch.schemas.contrast import ContrastSet
37
+
38
+
39
+ @dataclass
40
+ class CandidateFeature:
41
+ """A feature ranked by its activation difference across a contrast set."""
42
+
43
+ feature_id: int
44
+ mean_diff: float
45
+ """Mean(positive activation) - Mean(negative activation), normalized space."""
46
+ mean_positive: float
47
+ mean_negative: float
48
+ positive_fire_rate: float
49
+ negative_fire_rate: float
50
+ effect_size: float
51
+ """Standardised difference (Cohen's d style), using pooled per-token std."""
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "feature_id": self.feature_id,
56
+ "mean_diff": self.mean_diff,
57
+ "mean_positive": self.mean_positive,
58
+ "mean_negative": self.mean_negative,
59
+ "positive_fire_rate": self.positive_fire_rate,
60
+ "negative_fire_rate": self.negative_fire_rate,
61
+ "effect_size": self.effect_size,
62
+ "evidence": "correlational -- activation difference only, no causal test",
63
+ }
64
+
65
+
66
+ @torch.inference_mode()
67
+ def collect_response_activations(
68
+ bundle: ModelBundle,
69
+ texts: Sequence[str],
70
+ *,
71
+ layer: int,
72
+ prompts: Sequence[str] | None = None,
73
+ max_length: int = 384,
74
+ ) -> torch.Tensor:
75
+ """Capture residual activations over the *response* tokens only.
76
+
77
+ When ``prompts`` is supplied, activations from the prompt portion are
78
+ excluded. This matters: the prompt is identical across a contrast pair, so
79
+ including its activations would dilute the signal with tokens that carry no
80
+ information about the contrast.
81
+ """
82
+ capture = ResidualCapture(to_cpu=True, dtype=torch.float32)
83
+ handle = capture.attach(bundle.layer_module(layer))
84
+ collected: list[torch.Tensor] = []
85
+ try:
86
+ for i, text in enumerate(texts):
87
+ prompt = prompts[i] if prompts is not None else None
88
+ full = (prompt + text) if prompt else text
89
+ encoded = bundle.tokenizer(
90
+ full, return_tensors="pt", truncation=True, max_length=max_length
91
+ ).to(bundle.device)
92
+ capture.activations = None
93
+ bundle.model(**encoded, use_cache=False)
94
+ hidden = capture.activations
95
+ if hidden is None:
96
+ raise RuntimeError("capture hook did not fire during contrast collection")
97
+
98
+ start = 0
99
+ if prompt:
100
+ prompt_len = bundle.tokenizer(
101
+ prompt, return_tensors="pt", truncation=True, max_length=max_length
102
+ ).input_ids.shape[1]
103
+ start = min(prompt_len, hidden.shape[1] - 1)
104
+ collected.append(hidden[0, start:, :])
105
+ finally:
106
+ handle.remove()
107
+ return torch.cat(collected, dim=0)
108
+
109
+
110
+ def rank_candidate_features(
111
+ bundle: ModelBundle,
112
+ sae: TopKSAE,
113
+ contrast_set: ContrastSet,
114
+ *,
115
+ layer: int,
116
+ input_scale: float,
117
+ top_n: int = 20,
118
+ use_chat_template: bool = True,
119
+ min_fire_rate: float = 0.01,
120
+ max_fire_rate: float = 0.9,
121
+ ) -> list[CandidateFeature]:
122
+ """Rank features by how differently they fire on positive vs negative responses.
123
+
124
+ Parameters
125
+ ----------
126
+ min_fire_rate, max_fire_rate:
127
+ Discard features that essentially never fire (no signal) or fire almost
128
+ everywhere (modelling something global rather than the contrast).
129
+
130
+ Returns
131
+ -------
132
+ list[CandidateFeature]
133
+ Sorted by absolute effect size, longest-first, truncated to ``top_n``.
134
+ **Correlational evidence only.**
135
+ """
136
+ prompts = [
137
+ build_chat_prompt(bundle.tokenizer, e.prompt) if use_chat_template else e.prompt
138
+ for e in contrast_set
139
+ ]
140
+ positives = [e.positive_response for e in contrast_set]
141
+ negatives = [e.negative_response for e in contrast_set]
142
+
143
+ pos_acts = collect_response_activations(bundle, positives, layer=layer, prompts=prompts)
144
+ neg_acts = collect_response_activations(bundle, negatives, layer=layer, prompts=prompts)
145
+
146
+ sae.eval()
147
+ device = next(sae.parameters()).device
148
+ pos_features = _encode_in_batches(sae, pos_acts, input_scale, device)
149
+ neg_features = _encode_in_batches(sae, neg_acts, input_scale, device)
150
+
151
+ pos_mean = pos_features.mean(dim=0)
152
+ neg_mean = neg_features.mean(dim=0)
153
+ pos_rate = (pos_features > 0).float().mean(dim=0)
154
+ neg_rate = (neg_features > 0).float().mean(dim=0)
155
+
156
+ pooled_std = torch.sqrt(
157
+ (pos_features.var(dim=0, unbiased=False) + neg_features.var(dim=0, unbiased=False)) / 2
158
+ ).clamp_min(1e-6)
159
+ diff = pos_mean - neg_mean
160
+ effect = diff / pooled_std
161
+
162
+ overall_rate = (pos_rate + neg_rate) / 2
163
+ eligible = (overall_rate >= min_fire_rate) & (overall_rate <= max_fire_rate)
164
+ effect = torch.where(eligible, effect, torch.zeros_like(effect))
165
+
166
+ order = torch.argsort(effect.abs(), descending=True)[:top_n]
167
+ return [
168
+ CandidateFeature(
169
+ feature_id=int(fid),
170
+ mean_diff=float(diff[fid].item()),
171
+ mean_positive=float(pos_mean[fid].item()),
172
+ mean_negative=float(neg_mean[fid].item()),
173
+ positive_fire_rate=float(pos_rate[fid].item()),
174
+ negative_fire_rate=float(neg_rate[fid].item()),
175
+ effect_size=float(effect[fid].item()),
176
+ )
177
+ for fid in order.tolist()
178
+ if effect[fid] != 0
179
+ ]
180
+
181
+
182
+ def _encode_in_batches(
183
+ sae: TopKSAE, acts: torch.Tensor, input_scale: float, device: torch.device, batch: int = 4096
184
+ ) -> torch.Tensor:
185
+ chunks: list[torch.Tensor] = []
186
+ for start in range(0, acts.shape[0], batch):
187
+ x = acts[start : start + batch].to(device=device, dtype=torch.float32) * input_scale
188
+ sparse, _, _ = sae.encode(x)
189
+ chunks.append(sparse.cpu())
190
+ return torch.cat(chunks, dim=0)
191
+
192
+
193
+ @dataclass
194
+ class SweepPoint:
195
+ """One point of a strength sweep."""
196
+
197
+ strength: float
198
+ mean_divergence: float
199
+ mean_words: float
200
+ degeneration_rate: float
201
+ generations: list[str] = field(default_factory=list)
202
+
203
+ def to_dict(self) -> dict[str, Any]:
204
+ return {
205
+ "strength": self.strength,
206
+ "mean_divergence": self.mean_divergence,
207
+ "mean_words": self.mean_words,
208
+ "degeneration_rate": self.degeneration_rate,
209
+ }
210
+
211
+
212
+ def strength_sweep(
213
+ model: Any,
214
+ prompts: Sequence[str],
215
+ *,
216
+ feature_id: int,
217
+ strengths: Sequence[float],
218
+ generation: Any = None,
219
+ ) -> list[SweepPoint]:
220
+ """Sweep one feature's strength and measure divergence and degeneration.
221
+
222
+ The purpose is to find the window where an intervention changes behaviour
223
+ *before* it destroys fluency. Every sweep point costs
224
+ ``len(prompts)`` generations, so keep both lists short.
225
+ """
226
+ from brainpatch.evaluation.metrics import jaccard_similarity, score_generation
227
+ from brainpatch.research.ml.causal import _spec_for
228
+ from brainpatch.research.ml.generation import GenerationConfig
229
+
230
+ cfg = generation or GenerationConfig(max_new_tokens=96)
231
+ saved = dict(model.plan.patches)
232
+
233
+ model.plan.patches = {}
234
+ baselines = [model.generate(p, config=cfg) for p in prompts]
235
+
236
+ points: list[SweepPoint] = []
237
+ for strength in strengths:
238
+ divergences: list[float] = []
239
+ words: list[int] = []
240
+ degenerate = 0
241
+ texts: list[str] = []
242
+ for prompt, baseline in zip(prompts, baselines):
243
+ model.plan.patches = {}
244
+ model.install(_spec_for(model, feature_id, strength, f"sweep-{feature_id}"))
245
+ text = model.generate(prompt, config=cfg)
246
+ metrics = score_generation(text)
247
+ divergences.append(1.0 - jaccard_similarity(baseline, text, n=3))
248
+ words.append(metrics.num_words)
249
+ degenerate += int(metrics.degeneration_flag)
250
+ texts.append(text)
251
+ points.append(
252
+ SweepPoint(
253
+ strength=float(strength),
254
+ mean_divergence=sum(divergences) / len(divergences),
255
+ mean_words=sum(words) / len(words),
256
+ degeneration_rate=degenerate / len(prompts),
257
+ generations=texts,
258
+ )
259
+ )
260
+
261
+ model.plan.patches = saved
262
+ return points
263
+
264
+
265
+ def greedy_feature_selection(
266
+ model: Any,
267
+ prompts: Sequence[str],
268
+ candidates: Sequence[CandidateFeature],
269
+ *,
270
+ strength: float,
271
+ max_features: int = 3,
272
+ generation: Any = None,
273
+ degeneration_ceiling: float = 0.25,
274
+ ) -> list[tuple[int, float]]:
275
+ """Greedily build a sparse multi-feature patch.
276
+
277
+ At each round, every remaining candidate is added tentatively and the one
278
+ producing the largest divergence-from-baseline *without* pushing the
279
+ degeneration rate above ``degeneration_ceiling`` is kept. Selection stops
280
+ when no candidate improves the objective.
281
+
282
+ Cost is ``O(max_features * len(candidates) * len(prompts))`` generations.
283
+ Keep the candidate list to single digits.
284
+ """
285
+ from brainpatch.evaluation.metrics import jaccard_similarity, score_generation
286
+ from brainpatch.research.ml.causal import _spec_for
287
+ from brainpatch.research.ml.generation import GenerationConfig
288
+ from brainpatch.schemas.patch import BrainPatchSpec, FeatureEdit
289
+
290
+ cfg = generation or GenerationConfig(max_new_tokens=96)
291
+ saved = dict(model.plan.patches)
292
+
293
+ model.plan.patches = {}
294
+ baselines = [model.generate(p, config=cfg) for p in prompts]
295
+
296
+ selected: list[tuple[int, float]] = []
297
+ remaining = [c.feature_id for c in candidates]
298
+ best_score = 0.0
299
+
300
+ def evaluate(edits: list[tuple[int, float]]) -> tuple[float, float]:
301
+ base_spec = _spec_for(model, edits[0][0], edits[0][1], "greedy")
302
+ spec = BrainPatchSpec(
303
+ name="greedy",
304
+ base_model=base_spec.base_model,
305
+ model_revision=base_spec.model_revision,
306
+ sae=base_spec.sae,
307
+ features=[FeatureEdit(feature_id=f, strength=s) for f, s in edits],
308
+ description="Transient greedy-search candidate.",
309
+ )
310
+ divergences: list[float] = []
311
+ degenerate = 0
312
+ for prompt, baseline in zip(prompts, baselines):
313
+ model.plan.patches = {}
314
+ model.install(spec)
315
+ text = model.generate(prompt, config=cfg)
316
+ divergences.append(1.0 - jaccard_similarity(baseline, text, n=3))
317
+ degenerate += int(score_generation(text).degeneration_flag)
318
+ return sum(divergences) / len(divergences), degenerate / len(prompts)
319
+
320
+ for _ in range(max_features):
321
+ best_feature: int | None = None
322
+ best_round = best_score
323
+ for feature_id in list(remaining):
324
+ score, degeneration = evaluate([*selected, (feature_id, strength)])
325
+ if degeneration > degeneration_ceiling:
326
+ continue
327
+ if score > best_round:
328
+ best_round = score
329
+ best_feature = feature_id
330
+ if best_feature is None:
331
+ break
332
+ selected.append((best_feature, strength))
333
+ remaining.remove(best_feature)
334
+ best_score = best_round
335
+
336
+ model.plan.patches = saved
337
+ return selected
@@ -0,0 +1,343 @@
1
+ """``BrainPatchedModel`` -- the user-facing runtime.
2
+
3
+ ::
4
+
5
+ from brainpatch import BrainPatchedModel
6
+
7
+ model = BrainPatchedModel.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
8
+ model.load_sae("/vol/sae/smoke_v0/sae_latest.pt", reference="smoke_v0")
9
+ model.install("patches/experimental-feature-1207.json")
10
+ model.set_patch_strength("experimental-feature-1207", 1.5)
11
+ print(model.generate("Solve this problem..."))
12
+
13
+ The base model is frozen. Every behavioural change comes from a hook that adds
14
+ a vector to one layer's residual stream, installed for the duration of a
15
+ generation and removed afterwards.
16
+
17
+ Two invariants this class exists to guarantee:
18
+
19
+ 1. **No patches installed, or all strengths zero, is exactly baseline.** With
20
+ nothing to apply, the hook returns ``None`` and the tensor is never touched.
21
+ :meth:`assert_zero_strength_is_baseline` verifies this empirically rather
22
+ than trusting the argument.
23
+
24
+ 2. **A patch cannot be applied to the wrong model.** Every install runs
25
+ :meth:`~brainpatch.schemas.patch.BrainPatchSpec.check_compatibility` against
26
+ the loaded weights and SAE.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ import torch
36
+
37
+ from brainpatch.research.ml.generation import GenerationConfig, build_chat_prompt
38
+ from brainpatch.research.ml.hooks import HookSet
39
+ from brainpatch.research.ml.intervention import FeatureSteerer, make_steerer
40
+ from brainpatch.research.ml.model import DEFAULT_MODEL, ModelBundle, load_model
41
+ from brainpatch.research.ml.sae import TopKSAE
42
+ from brainpatch.schemas.patch_io import load_patch
43
+ from brainpatch.schemas.patch import BrainPatchSpec, FeatureEdit, SAEReference
44
+ from brainpatch.steering.plan import InterventionPlan
45
+ from brainpatch.steering.schedule import StrengthSchedule
46
+
47
+
48
+ class BrainPatchedModel:
49
+ """A frozen language model with installable activation-space patches."""
50
+
51
+ def __init__(self, bundle: ModelBundle) -> None:
52
+ self.bundle = bundle
53
+ self.plan = InterventionPlan()
54
+ self.sae: TopKSAE | None = None
55
+ self.sae_reference: str | None = None
56
+ self.input_scale: float | None = None
57
+ self._last_stats: dict[str, Any] = {}
58
+ self._last_trace: list[tuple[int, float]] = []
59
+
60
+ # -- construction ----------------------------------------------------------
61
+
62
+ @classmethod
63
+ def from_pretrained(
64
+ cls,
65
+ model_id: str = DEFAULT_MODEL,
66
+ *,
67
+ revision: str | None = None,
68
+ dtype: str = "bfloat16",
69
+ device: str = "cuda",
70
+ ) -> "BrainPatchedModel":
71
+ """Load a frozen base model. Downloads go to the HF cache, never locally."""
72
+ return cls(load_model(model_id, revision=revision, dtype=dtype, device=device))
73
+
74
+ def load_sae(
75
+ self,
76
+ checkpoint_path: str | os.PathLike[str],
77
+ *,
78
+ reference: str,
79
+ input_scale: float | None = None,
80
+ ) -> TopKSAE:
81
+ """Attach a trained SAE, whose decoder columns become patch directions.
82
+
83
+ Raises
84
+ ------
85
+ ValueError
86
+ If the SAE's input width does not match the model's residual width,
87
+ which would mean the two were never trained on the same activations.
88
+ """
89
+ path = Path(checkpoint_path)
90
+ if not path.is_file():
91
+ raise FileNotFoundError(f"SAE checkpoint not found: {path}")
92
+ checkpoint = torch.load(path, map_location="cpu", weights_only=False)
93
+ sae = TopKSAE.from_checkpoint(checkpoint, device=str(self.bundle.device))
94
+
95
+ if sae.d_in != self.bundle.hidden_size:
96
+ raise ValueError(
97
+ f"SAE was trained on width {sae.d_in} but the model's residual "
98
+ f"stream is {self.bundle.hidden_size} wide -- these are not the "
99
+ "same activations"
100
+ )
101
+
102
+ scale = input_scale if input_scale is not None else sae.config.input_scale
103
+ if scale is None:
104
+ raise ValueError(
105
+ "SAE checkpoint has no recorded input_scale, so a strength value "
106
+ "would have no defined magnitude. Pass input_scale explicitly."
107
+ )
108
+
109
+ self.sae = sae
110
+ self.sae_reference = reference
111
+ self.input_scale = float(scale)
112
+ return sae
113
+
114
+ # -- patch management ------------------------------------------------------
115
+
116
+ def install(
117
+ self,
118
+ patch: str | os.PathLike[str] | BrainPatchSpec,
119
+ *,
120
+ strength: float = 1.0,
121
+ strict_revision: bool = False,
122
+ ) -> BrainPatchSpec:
123
+ """Install a patch from a file path or an in-memory spec.
124
+
125
+ The compatibility check runs before anything is registered, so a
126
+ rejected patch leaves the runtime untouched.
127
+ """
128
+ spec = patch if isinstance(patch, BrainPatchSpec) else load_patch(patch)
129
+ spec.check_compatibility(
130
+ model=self.bundle.model_id,
131
+ hidden_size=self.bundle.hidden_size,
132
+ num_layers=self.bundle.num_layers,
133
+ model_revision=self.bundle.revision,
134
+ sae_reference=self.sae_reference,
135
+ sae_d_sae=self.sae.d_sae if self.sae is not None else None,
136
+ strict_revision=strict_revision,
137
+ )
138
+ self.plan.install(spec, strength=strength)
139
+ return spec
140
+
141
+ def uninstall(self, name: str) -> None:
142
+ self.plan.uninstall(name)
143
+
144
+ def list_patches(self) -> list[str]:
145
+ return list(self.plan.patches)
146
+
147
+ def set_patch_strength(self, name: str, strength: float) -> None:
148
+ """Change a patch's strength. Takes effect on the next generation."""
149
+ self.plan.set_strength(name, strength)
150
+
151
+ def set_patch_enabled(self, name: str, enabled: bool) -> None:
152
+ self.plan.set_enabled(name, enabled)
153
+
154
+ def set_patch_schedule(self, name: str, schedule: dict[int, float] | StrengthSchedule | None) -> None:
155
+ """Install a token-indexed strength schedule for dynamic steering."""
156
+ if isinstance(schedule, dict):
157
+ schedule = StrengthSchedule(schedule)
158
+ self.plan.set_schedule(name, schedule)
159
+
160
+ def add_feature(
161
+ self,
162
+ *,
163
+ layer: int,
164
+ feature_id: int,
165
+ strength: float,
166
+ name: str | None = None,
167
+ mode: str = "add",
168
+ ) -> BrainPatchSpec:
169
+ """Install an ad-hoc single-feature intervention.
170
+
171
+ The convenience path for exploration. It builds a real
172
+ :class:`BrainPatchSpec` under the hood, so an interactive experiment and
173
+ a shipped patch go through identical machinery -- and the resulting spec
174
+ can be saved directly.
175
+ """
176
+ if self.sae is None or self.sae_reference is None or self.input_scale is None:
177
+ raise RuntimeError("load_sae() must be called before adding features")
178
+ if layer != self.bundle_layer_for_sae():
179
+ # Not fatal, but the SAE only means anything at the layer it was fitted on.
180
+ raise ValueError(
181
+ f"SAE {self.sae_reference!r} was trained at layer "
182
+ f"{self.sae.config.layer}; refusing to inject its directions at layer {layer}"
183
+ )
184
+ spec = BrainPatchSpec(
185
+ name=name or f"adhoc-feature-{feature_id}",
186
+ base_model=self.bundle.model_id,
187
+ model_revision=self.bundle.revision,
188
+ sae=SAEReference(
189
+ reference=self.sae_reference,
190
+ layer=layer,
191
+ hook=self.sae.config.hook or "residual_post",
192
+ d_in=self.sae.d_in,
193
+ d_sae=self.sae.d_sae,
194
+ input_scale=self.input_scale,
195
+ ),
196
+ features=[FeatureEdit(feature_id=feature_id, strength=strength, mode=mode)],
197
+ description="Ad-hoc exploratory intervention. No evidence of any behavioural effect.",
198
+ evidence_level="none",
199
+ )
200
+ return self.install(spec)
201
+
202
+ def bundle_layer_for_sae(self) -> int:
203
+ """The layer the attached SAE was trained on."""
204
+ if self.sae is None:
205
+ raise RuntimeError("no SAE loaded")
206
+ return int(self.sae.config.layer)
207
+
208
+ # -- generation ------------------------------------------------------------
209
+
210
+ def generate(
211
+ self,
212
+ prompt: str,
213
+ *,
214
+ config: GenerationConfig | None = None,
215
+ system: str | None = None,
216
+ use_chat_template: bool = True,
217
+ control: str = "none",
218
+ control_seed: int = 1234,
219
+ apply_to_prompt: bool = True,
220
+ ) -> str:
221
+ """Generate a completion with all installed patches active.
222
+
223
+ Parameters
224
+ ----------
225
+ control:
226
+ ``"random"`` swaps every feature direction for a scale-matched
227
+ random one, keeping all else identical. This is the control
228
+ condition, run through exactly the same code path as the real
229
+ intervention.
230
+ """
231
+ cfg = config or GenerationConfig()
232
+ text = build_chat_prompt(self.bundle.tokenizer, prompt, system) if use_chat_template else prompt
233
+ inputs = self.bundle.tokenizer(text, return_tensors="pt").to(self.bundle.device)
234
+
235
+ if cfg.do_sample:
236
+ torch.manual_seed(cfg.seed)
237
+
238
+ steerer = self._build_steerer(control=control, control_seed=control_seed,
239
+ apply_to_prompt=apply_to_prompt)
240
+
241
+ with HookSet() as hooks:
242
+ if steerer is not None:
243
+ steerer.reset()
244
+ hooks.add(steerer.make_injector(), self.bundle.layer_module(steerer.layer))
245
+ with torch.inference_mode():
246
+ output = self.bundle.model.generate(
247
+ **inputs, **cfg.to_kwargs(self.bundle.tokenizer)
248
+ )
249
+
250
+ self._last_stats = steerer.stats.to_dict() if steerer is not None else {}
251
+ self._last_trace = list(steerer.stats.per_token_norms) if steerer is not None else []
252
+ generated = output[0, inputs["input_ids"].shape[1] :]
253
+ return self.bundle.tokenizer.decode(generated, skip_special_tokens=True)
254
+
255
+ @property
256
+ def last_steering_stats(self) -> dict[str, Any]:
257
+ """Delta norms and application counts from the most recent generation.
258
+
259
+ The empirical answer to "did the intervention actually fire?" -- an
260
+ ``applied_passes`` of 0 means the plan resolved to nothing.
261
+ """
262
+ return dict(self._last_stats)
263
+
264
+ @property
265
+ def last_steering_trace(self) -> list[tuple[int, float]]:
266
+ """``(generated_token_index, delta_norm)`` for every pass of the last run.
267
+
268
+ The evidence that a dynamic schedule fired where it was supposed to:
269
+ the norm should be zero before the keyframe and non-zero after.
270
+ """
271
+ return list(self._last_trace)
272
+
273
+ def _build_steerer(
274
+ self, *, control: str, control_seed: int, apply_to_prompt: bool
275
+ ) -> FeatureSteerer | None:
276
+ """Construct a steerer, or ``None`` when there is nothing to apply."""
277
+ layers = self.plan.layers()
278
+ if not layers:
279
+ return None
280
+ if self.sae is None or self.input_scale is None:
281
+ raise RuntimeError("patches are installed but no SAE is loaded")
282
+ if len(layers) > 1:
283
+ raise NotImplementedError(
284
+ f"patches span layers {layers}; multi-layer steering needs one SAE "
285
+ "per layer and is not supported in v0"
286
+ )
287
+ return make_steerer(
288
+ self.sae,
289
+ self.plan,
290
+ layer=layers[0],
291
+ input_scale=self.input_scale,
292
+ device=self.bundle.device,
293
+ control=control,
294
+ control_seed=control_seed,
295
+ apply_to_prompt=apply_to_prompt,
296
+ )
297
+
298
+ # -- verification ----------------------------------------------------------
299
+
300
+ def assert_zero_strength_is_baseline(
301
+ self, prompt: str, *, config: GenerationConfig | None = None
302
+ ) -> dict[str, Any]:
303
+ """Empirically verify that zeroed patches reproduce baseline exactly.
304
+
305
+ Generates with every patch uninstalled, then with them installed at
306
+ strength 0, and compares the strings. This is a correctness test of the
307
+ hook machinery: if it ever fails, every "baseline" in every experiment
308
+ is suspect.
309
+ """
310
+ cfg = config or GenerationConfig(max_new_tokens=48)
311
+ saved = {name: p.strength for name, p in self.plan.patches.items()}
312
+
313
+ installed = dict(self.plan.patches)
314
+ self.plan.patches = {}
315
+ baseline = self.generate(prompt, config=cfg)
316
+
317
+ self.plan.patches = installed
318
+ for name in self.plan.patches:
319
+ self.plan.set_strength(name, 0.0)
320
+ zeroed = self.generate(prompt, config=cfg)
321
+ zero_stats = self.last_steering_stats
322
+
323
+ for name, strength in saved.items():
324
+ self.plan.set_strength(name, strength)
325
+
326
+ return {
327
+ "identical": baseline == zeroed,
328
+ "baseline": baseline,
329
+ "zero_strength": zeroed,
330
+ "applied_passes_at_zero": zero_stats.get("applied_passes", 0),
331
+ "num_patches": len(saved),
332
+ }
333
+
334
+ def describe(self) -> dict[str, Any]:
335
+ return {
336
+ **self.bundle.describe(),
337
+ "sae_reference": self.sae_reference,
338
+ "sae_d_sae": self.sae.d_sae if self.sae else None,
339
+ "sae_k": self.sae.k if self.sae else None,
340
+ "sae_layer": self.sae.config.layer if self.sae else None,
341
+ "input_scale": self.input_scale,
342
+ "installed_patches": self.plan.describe(),
343
+ }