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,711 @@
1
+ """Direction discovery and behavioural scoring for `anti_sycophancy_v1`.
2
+
3
+ This module answers one question and is shaped entirely by it: *does a direction
4
+ in activation space move a specific behavioural preference on unseen prompts,
5
+ more than matched controls do?*
6
+
7
+ Everything here is built around three cost and correctness constraints.
8
+
9
+ **One model load.** GPU time is the budget. Activations for every candidate
10
+ layer are captured in a single forward pass per sequence, and every scoring pass
11
+ is batched, so the whole layer x position x method grid costs a few minutes
12
+ rather than an hour.
13
+
14
+ **Paired, per-token margins.** Each item contributes
15
+ ``margin = logP(desired)/n_desired - logP(undesired)/n_undesired`` under one
16
+ model state, and the reported quantity is the per-item *change* in that margin.
17
+ Pairing cancels item difficulty; per-token normalisation blunts the dataset's
18
+ known length skew.
19
+
20
+ **Discovery never touches validation or test.** Functions here take explicit
21
+ example lists. Nothing reaches for a global split.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Callable, Iterable, Sequence
29
+
30
+ import torch
31
+
32
+ #: Where along the sequence a direction is read off. Which of these works is an
33
+ #: empirical question, so it is scanned rather than assumed.
34
+ EXTRACTION_POSITIONS: tuple[str, ...] = ("last_prompt", "cont_mean", "cont_last")
35
+
36
+ #: Where a direction is injected during scoring. ``continuation`` is the
37
+ #: analogue of steering every generated token; ``prompt`` steers only the
38
+ #: context; ``all`` does both.
39
+ INJECTION_SITES: tuple[str, ...] = ("continuation", "prompt", "all")
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # tokenisation
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ @dataclass
48
+ class EncodedPair:
49
+ """One item's two continuations, tokenised and ready to batch."""
50
+
51
+ topic: str
52
+ category: str
53
+ polarity: str
54
+ split: str
55
+ desired_ids: list[int]
56
+ undesired_ids: list[int]
57
+ prompt_len: int
58
+
59
+ @property
60
+ def n_desired(self) -> int:
61
+ return len(self.desired_ids) - self.prompt_len
62
+
63
+ @property
64
+ def n_undesired(self) -> int:
65
+ return len(self.undesired_ids) - self.prompt_len
66
+
67
+ @property
68
+ def length_gap(self) -> int:
69
+ """Desired minus undesired continuation length, in tokens.
70
+
71
+ Kept per item so the confound diagnostic can be computed directly
72
+ rather than estimated from character counts.
73
+ """
74
+ return self.n_desired - self.n_undesired
75
+
76
+
77
+ def encode_pairs(
78
+ tokenizer: Any,
79
+ examples: Sequence[Any],
80
+ *,
81
+ use_chat_template: bool = True,
82
+ max_length: int = 512,
83
+ ) -> list[EncodedPair]:
84
+ """Tokenise contrast examples once, up front.
85
+
86
+ Both continuations share a prompt, and the prompt is tokenised *once* so
87
+ ``prompt_len`` is guaranteed identical for the pair. Tokenising
88
+ ``prompt + continuation`` separately for each side and subtracting lengths
89
+ would let a boundary merge shift one side by a token, which silently
90
+ misaligns the scored region.
91
+ """
92
+ encoded: list[EncodedPair] = []
93
+ for example in examples:
94
+ if use_chat_template:
95
+ prompt = tokenizer.apply_chat_template(
96
+ [{"role": "user", "content": example.prompt}],
97
+ tokenize=False,
98
+ add_generation_prompt=True,
99
+ )
100
+ else:
101
+ prompt = example.prompt
102
+
103
+ prompt_ids = tokenizer(prompt, add_special_tokens=False).input_ids
104
+ desired = prompt_ids + tokenizer(
105
+ example.positive_response, add_special_tokens=False
106
+ ).input_ids
107
+ undesired = prompt_ids + tokenizer(
108
+ example.negative_response, add_special_tokens=False
109
+ ).input_ids
110
+
111
+ metadata = getattr(example, "metadata", {}) or {}
112
+ encoded.append(
113
+ EncodedPair(
114
+ topic=str(metadata.get("topic", "")),
115
+ category=str(getattr(example, "category", "")),
116
+ polarity=str(metadata.get("polarity", "false_claim")),
117
+ split=str(metadata.get("split", "train")),
118
+ desired_ids=desired[:max_length],
119
+ undesired_ids=undesired[:max_length],
120
+ prompt_len=len(prompt_ids),
121
+ )
122
+ )
123
+ return encoded
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # injection
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ class DirectionInjector:
132
+ """Adds ``strength * unit(direction)`` to one layer's residual stream.
133
+
134
+ The additive mask is set per batch, which is what allows the same hook to
135
+ express "steer only the continuation", "steer only the prompt" and "steer
136
+ everything" without reinstalling anything.
137
+ """
138
+
139
+ def __init__(self, direction: torch.Tensor, strength: float) -> None:
140
+ norm = torch.linalg.vector_norm(direction)
141
+ if float(norm) <= 0:
142
+ raise ValueError("direction has zero norm")
143
+ self.vector = (direction / norm).detach()
144
+ self.strength = float(strength)
145
+ self.mask: torch.Tensor | None = None
146
+ self._handle: Any = None
147
+ self.calls = 0
148
+
149
+ def _hook(self, module: Any, args: Any, output: Any) -> Any:
150
+ if self.mask is None or self.strength == 0.0:
151
+ return output
152
+ hidden = output[0] if isinstance(output, tuple) else output
153
+ delta = self.mask.unsqueeze(-1).to(hidden.dtype) * self.vector.to(
154
+ hidden.device, hidden.dtype
155
+ )
156
+ hidden = hidden + self.strength * delta
157
+ self.calls += 1
158
+ if isinstance(output, tuple):
159
+ return (hidden,) + tuple(output[1:])
160
+ return hidden
161
+
162
+ def attach(self, layer_module: Any) -> "DirectionInjector":
163
+ self._handle = layer_module.register_forward_hook(self._hook)
164
+ return self
165
+
166
+ def remove(self) -> None:
167
+ if self._handle is not None:
168
+ self._handle.remove()
169
+ self._handle = None
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # scoring
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ @dataclass
178
+ class ItemScore:
179
+ """One item's per-token margin under one model state."""
180
+
181
+ topic: str
182
+ category: str
183
+ polarity: str
184
+ desired_logprob: float
185
+ undesired_logprob: float
186
+ n_desired: int
187
+ n_undesired: int
188
+ length_gap: int
189
+
190
+ @property
191
+ def margin(self) -> float:
192
+ """Length-normalized margin: per-token, so length cannot drive it."""
193
+ return (
194
+ self.desired_logprob / max(1, self.n_desired)
195
+ - self.undesired_logprob / max(1, self.n_undesired)
196
+ )
197
+
198
+ @property
199
+ def total_margin(self) -> float:
200
+ """Total sequence log-probability margin.
201
+
202
+ Reported alongside the normalized version. On a length-balanced dataset
203
+ the two should broadly agree; a disagreement between them is itself
204
+ evidence that length is doing the work, so both are always carried.
205
+ """
206
+ return self.desired_logprob - self.undesired_logprob
207
+
208
+
209
+ def _sequence_logprobs(
210
+ model: Any,
211
+ batch_ids: list[list[int]],
212
+ prompt_lens: list[int],
213
+ pad_id: int,
214
+ device: Any,
215
+ injector: DirectionInjector | None,
216
+ inject_site: str,
217
+ ) -> list[float]:
218
+ """Summed continuation log-probability for each sequence in a batch.
219
+
220
+ Right-padded. Padding never enters a score because only positions in
221
+ ``[prompt_len-1, seq_len-1)`` are gathered, and the additive mask is zero on
222
+ pad positions so a padded run cannot perturb a real one.
223
+ """
224
+ width = max(len(ids) for ids in batch_ids)
225
+ input_ids = torch.full((len(batch_ids), width), pad_id, dtype=torch.long)
226
+ attention = torch.zeros((len(batch_ids), width), dtype=torch.long)
227
+ steer_mask = torch.zeros((len(batch_ids), width), dtype=torch.float32)
228
+
229
+ for row, (ids, prompt_len) in enumerate(zip(batch_ids, prompt_lens)):
230
+ input_ids[row, : len(ids)] = torch.tensor(ids, dtype=torch.long)
231
+ attention[row, : len(ids)] = 1
232
+ if inject_site == "continuation":
233
+ steer_mask[row, prompt_len : len(ids)] = 1.0
234
+ elif inject_site == "prompt":
235
+ steer_mask[row, :prompt_len] = 1.0
236
+ else:
237
+ steer_mask[row, : len(ids)] = 1.0
238
+
239
+ input_ids = input_ids.to(device)
240
+ attention = attention.to(device)
241
+ if injector is not None:
242
+ injector.mask = steer_mask.to(device)
243
+
244
+ with torch.inference_mode():
245
+ logits = model(input_ids=input_ids, attention_mask=attention, use_cache=False).logits
246
+
247
+ log_probs = torch.log_softmax(logits[:, :-1, :].float(), dim=-1)
248
+ targets = input_ids[:, 1:]
249
+ gathered = log_probs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
250
+
251
+ out: list[float] = []
252
+ for row, (ids, prompt_len) in enumerate(zip(batch_ids, prompt_lens)):
253
+ out.append(float(gathered[row, prompt_len - 1 : len(ids) - 1].sum().item()))
254
+ return out
255
+
256
+
257
+ def score_pairs(
258
+ model: Any,
259
+ pairs: Sequence[EncodedPair],
260
+ *,
261
+ pad_id: int,
262
+ device: Any,
263
+ injector: DirectionInjector | None = None,
264
+ inject_site: str = "continuation",
265
+ batch_size: int = 8,
266
+ ) -> list[ItemScore]:
267
+ """Per-token margins for every pair under the current model state.
268
+
269
+ Both sides of every pair go through the same batching path, so any batching
270
+ artefact affects desired and undesired identically and cancels in the
271
+ margin.
272
+ """
273
+ flat_ids: list[list[int]] = []
274
+ flat_prompt: list[int] = []
275
+ for pair in pairs:
276
+ flat_ids.append(pair.desired_ids)
277
+ flat_prompt.append(pair.prompt_len)
278
+ flat_ids.append(pair.undesired_ids)
279
+ flat_prompt.append(pair.prompt_len)
280
+
281
+ # Length-sorted batching keeps padding low; the original order is restored
282
+ # afterwards so results line up with `pairs`.
283
+ order = sorted(range(len(flat_ids)), key=lambda i: len(flat_ids[i]))
284
+ totals: list[float] = [0.0] * len(flat_ids)
285
+ for start in range(0, len(order), batch_size):
286
+ chunk = order[start : start + batch_size]
287
+ values = _sequence_logprobs(
288
+ model,
289
+ [flat_ids[i] for i in chunk],
290
+ [flat_prompt[i] for i in chunk],
291
+ pad_id,
292
+ device,
293
+ injector,
294
+ inject_site,
295
+ )
296
+ for index, value in zip(chunk, values):
297
+ totals[index] = value
298
+
299
+ scores: list[ItemScore] = []
300
+ for position, pair in enumerate(pairs):
301
+ scores.append(
302
+ ItemScore(
303
+ topic=pair.topic,
304
+ category=pair.category,
305
+ polarity=pair.polarity,
306
+ desired_logprob=totals[2 * position],
307
+ undesired_logprob=totals[2 * position + 1],
308
+ n_desired=pair.n_desired,
309
+ n_undesired=pair.n_undesired,
310
+ length_gap=pair.length_gap,
311
+ )
312
+ )
313
+ return scores
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # statistics
318
+ # ---------------------------------------------------------------------------
319
+
320
+
321
+ @dataclass
322
+ class DeltaSummary:
323
+ """Paired change in margin for one polarity."""
324
+
325
+ polarity: str
326
+ n: int
327
+ mean: float
328
+ median: float
329
+ ci_low: float
330
+ ci_high: float
331
+ proportion_improved: float
332
+ cohens_d: float
333
+ by_category: dict[str, float] = field(default_factory=dict)
334
+
335
+ def to_dict(self) -> dict[str, Any]:
336
+ return {
337
+ "polarity": self.polarity,
338
+ "n": self.n,
339
+ "mean": self.mean,
340
+ "median": self.median,
341
+ "ci_low": self.ci_low,
342
+ "ci_high": self.ci_high,
343
+ "proportion_improved": self.proportion_improved,
344
+ "cohens_d": self.cohens_d,
345
+ "by_category": self.by_category,
346
+ }
347
+
348
+
349
+ def _bootstrap_ci(
350
+ values: Sequence[float], *, resamples: int = 10_000, seed: int = 0
351
+ ) -> tuple[float, float]:
352
+ if not values:
353
+ return (float("nan"), float("nan"))
354
+ generator = torch.Generator().manual_seed(seed)
355
+ tensor = torch.tensor(values, dtype=torch.float64)
356
+ indices = torch.randint(
357
+ 0, len(values), (resamples, len(values)), generator=generator
358
+ )
359
+ means = tensor[indices].mean(dim=1)
360
+ low = float(torch.quantile(means, 0.025).item())
361
+ high = float(torch.quantile(means, 0.975).item())
362
+ return (low, high)
363
+
364
+
365
+ def summarize_deltas(
366
+ baseline: Sequence[ItemScore],
367
+ patched: Sequence[ItemScore],
368
+ *,
369
+ polarity: str,
370
+ seed: int = 0,
371
+ use_total: bool = False,
372
+ ) -> DeltaSummary:
373
+ """Paired delta statistics restricted to one polarity.
374
+
375
+ Pairs are matched by position, and a mismatch raises rather than silently
376
+ comparing different items -- a misalignment here would produce a plausible
377
+ number that means nothing.
378
+ """
379
+ if len(baseline) != len(patched):
380
+ raise ValueError("baseline and patched score lists differ in length")
381
+
382
+ deltas: list[float] = []
383
+ categories: dict[str, list[float]] = {}
384
+ for before, after in zip(baseline, patched):
385
+ if before.topic != after.topic:
386
+ raise ValueError(f"score misalignment: {before.topic!r} vs {after.topic!r}")
387
+ if before.polarity != polarity:
388
+ continue
389
+ if use_total:
390
+ delta = after.total_margin - before.total_margin
391
+ else:
392
+ delta = after.margin - before.margin
393
+ deltas.append(delta)
394
+ categories.setdefault(before.category, []).append(delta)
395
+
396
+ if not deltas:
397
+ return DeltaSummary(polarity, 0, 0.0, 0.0, float("nan"), float("nan"), 0.0, 0.0, {})
398
+
399
+ tensor = torch.tensor(deltas, dtype=torch.float64)
400
+ mean = float(tensor.mean().item())
401
+ std = float(tensor.std(unbiased=True).item()) if len(deltas) > 1 else 0.0
402
+ low, high = _bootstrap_ci(deltas, seed=seed)
403
+ return DeltaSummary(
404
+ polarity=polarity,
405
+ n=len(deltas),
406
+ mean=mean,
407
+ median=float(tensor.median().item()),
408
+ ci_low=low,
409
+ ci_high=high,
410
+ proportion_improved=float((tensor > 0).double().mean().item()),
411
+ cohens_d=(mean / std) if std > 0 else 0.0,
412
+ by_category={k: float(sum(v) / len(v)) for k, v in sorted(categories.items())},
413
+ )
414
+
415
+
416
+ def length_gap_correlation(
417
+ baseline: Sequence[ItemScore], patched: Sequence[ItemScore]
418
+ ) -> float:
419
+ """Pearson r between per-item delta and per-item token length gap.
420
+
421
+ The dataset's desired responses are systematically longer. If steering
422
+ strength tracks that gap, the "behavioural" effect is a length preference
423
+ wearing a costume, and the pre-registered criteria reject it.
424
+ """
425
+ deltas: list[float] = []
426
+ gaps: list[float] = []
427
+ for before, after in zip(baseline, patched):
428
+ deltas.append(after.margin - before.margin)
429
+ gaps.append(float(before.length_gap))
430
+ if len(deltas) < 3:
431
+ return 0.0
432
+ x = torch.tensor(deltas, dtype=torch.float64)
433
+ y = torch.tensor(gaps, dtype=torch.float64)
434
+ x = x - x.mean()
435
+ y = y - y.mean()
436
+ denominator = float(torch.linalg.vector_norm(x) * torch.linalg.vector_norm(y))
437
+ if denominator == 0:
438
+ return 0.0
439
+ return float((x @ y).item() / denominator)
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # direction discovery
444
+ # ---------------------------------------------------------------------------
445
+
446
+
447
+ def capture_layer_activations(
448
+ model: Any,
449
+ layer_modules: dict[int, Any],
450
+ pairs: Sequence[EncodedPair],
451
+ *,
452
+ pad_id: int,
453
+ device: Any,
454
+ batch_size: int = 8,
455
+ ) -> dict[int, dict[str, torch.Tensor]]:
456
+ """Residual activations at every candidate layer, at every extraction point.
457
+
458
+ One forward pass per sequence serves all layers and all three extraction
459
+ positions. Scanning layers with a separate pass each would multiply the
460
+ dominant cost of the experiment by seven for no information gain.
461
+
462
+ Returns ``{layer: {f"{position}_{side}": tensor of shape (n_items, hidden)}}``.
463
+ """
464
+ captured: dict[int, torch.Tensor] = {}
465
+ handles: list[Any] = []
466
+
467
+ def make_hook(layer: int) -> Callable[..., None]:
468
+ def hook(module: Any, args: Any, output: Any) -> None:
469
+ hidden = output[0] if isinstance(output, tuple) else output
470
+ captured[layer] = hidden.detach().float()
471
+
472
+ return hook
473
+
474
+ for layer, module in layer_modules.items():
475
+ handles.append(module.register_forward_hook(make_hook(layer)))
476
+
477
+ accumulator: dict[int, dict[str, list[torch.Tensor]]] = {
478
+ layer: {f"{p}_{s}": [] for p in EXTRACTION_POSITIONS for s in ("desired", "undesired")}
479
+ for layer in layer_modules
480
+ }
481
+
482
+ try:
483
+ for side, attribute in (("desired", "desired_ids"), ("undesired", "undesired_ids")):
484
+ for start in range(0, len(pairs), batch_size):
485
+ chunk = list(pairs[start : start + batch_size])
486
+ batch_ids = [getattr(p, attribute) for p in chunk]
487
+ width = max(len(ids) for ids in batch_ids)
488
+ input_ids = torch.full((len(chunk), width), pad_id, dtype=torch.long)
489
+ attention = torch.zeros((len(chunk), width), dtype=torch.long)
490
+ for row, ids in enumerate(batch_ids):
491
+ input_ids[row, : len(ids)] = torch.tensor(ids, dtype=torch.long)
492
+ attention[row, : len(ids)] = 1
493
+
494
+ with torch.inference_mode():
495
+ model(
496
+ input_ids=input_ids.to(device),
497
+ attention_mask=attention.to(device),
498
+ use_cache=False,
499
+ )
500
+
501
+ for layer in layer_modules:
502
+ hidden = captured[layer]
503
+ for row, (pair, ids) in enumerate(zip(chunk, batch_ids)):
504
+ start_index = pair.prompt_len
505
+ end_index = len(ids)
506
+ accumulator[layer][f"last_prompt_{side}"].append(
507
+ hidden[row, start_index - 1].cpu()
508
+ )
509
+ if end_index > start_index:
510
+ accumulator[layer][f"cont_mean_{side}"].append(
511
+ hidden[row, start_index:end_index].mean(dim=0).cpu()
512
+ )
513
+ accumulator[layer][f"cont_last_{side}"].append(
514
+ hidden[row, end_index - 1].cpu()
515
+ )
516
+ else: # degenerate pair; keep alignment
517
+ accumulator[layer][f"cont_mean_{side}"].append(
518
+ hidden[row, start_index - 1].cpu()
519
+ )
520
+ accumulator[layer][f"cont_last_{side}"].append(
521
+ hidden[row, start_index - 1].cpu()
522
+ )
523
+ finally:
524
+ for handle in handles:
525
+ handle.remove()
526
+
527
+ return {
528
+ layer: {key: torch.stack(values) for key, values in buckets.items()}
529
+ for layer, buckets in accumulator.items()
530
+ }
531
+
532
+
533
+ def fit_caa(desired: torch.Tensor, undesired: torch.Tensor) -> torch.Tensor:
534
+ """Difference of means. The baseline every other method has to beat."""
535
+ return desired.mean(dim=0) - undesired.mean(dim=0)
536
+
537
+
538
+ def fit_pca(desired: torch.Tensor, undesired: torch.Tensor) -> torch.Tensor:
539
+ """Top principal component of the paired difference distribution.
540
+
541
+ Sign is fixed to agree with the mean difference, since a principal component
542
+ is only defined up to sign and an arbitrary flip would invert the steering.
543
+ """
544
+ difference = desired - undesired
545
+ centered = difference - difference.mean(dim=0, keepdim=True)
546
+ _, _, v = torch.pca_lowrank(centered, q=min(8, centered.shape[0] - 1, centered.shape[1]))
547
+ component = v[:, 0]
548
+ if float(component @ difference.mean(dim=0)) < 0:
549
+ component = -component
550
+ return component
551
+
552
+
553
+ def fit_probe(
554
+ desired: torch.Tensor,
555
+ undesired: torch.Tensor,
556
+ *,
557
+ epochs: int = 300,
558
+ lr: float = 0.05,
559
+ weight_decay: float = 1e-3,
560
+ seed: int = 0,
561
+ ) -> tuple[torch.Tensor, float]:
562
+ """Logistic probe separating the two classes; returns (direction, accuracy).
563
+
564
+ Accuracy is returned so it can be *reported*, not so it can be used as a
565
+ success criterion. A probe can separate two classes almost perfectly and
566
+ still give a direction that steers nothing -- readable and causal are
567
+ different properties, and conflating them is a common way to overclaim.
568
+
569
+ Features are standardised before fitting and the learned weights are mapped
570
+ back to raw activation space, so the direction is comparable with CAA's.
571
+ """
572
+ torch.manual_seed(seed)
573
+ features = torch.cat([desired, undesired], dim=0).double()
574
+ labels = torch.cat(
575
+ [torch.ones(len(desired)), torch.zeros(len(undesired))], dim=0
576
+ ).double()
577
+
578
+ mean = features.mean(dim=0, keepdim=True)
579
+ std = features.std(dim=0, keepdim=True).clamp_min(1e-6)
580
+ standardized = (features - mean) / std
581
+
582
+ weights = torch.zeros(standardized.shape[1], dtype=torch.float64, requires_grad=True)
583
+ bias = torch.zeros(1, dtype=torch.float64, requires_grad=True)
584
+ optimizer = torch.optim.Adam([weights, bias], lr=lr, weight_decay=weight_decay)
585
+
586
+ for _ in range(epochs):
587
+ optimizer.zero_grad()
588
+ logits = standardized @ weights + bias
589
+ loss = torch.nn.functional.binary_cross_entropy_with_logits(logits, labels)
590
+ loss.backward()
591
+ optimizer.step()
592
+
593
+ with torch.no_grad():
594
+ predictions = ((standardized @ weights + bias) > 0).double()
595
+ accuracy = float((predictions == labels).double().mean().item())
596
+ direction = (weights / std.squeeze(0)).float()
597
+
598
+ return direction, accuracy
599
+
600
+
601
+ def random_directions(
602
+ hidden: int, count: int, *, seed: int = 0, device: Any = "cpu"
603
+ ) -> list[torch.Tensor]:
604
+ """Scale-matched random unit directions.
605
+
606
+ These are the control that the first BrainPatch experiment failed. Any
607
+ claimed effect has to beat the *maximum* over this set, not the mean.
608
+ """
609
+ generator = torch.Generator().manual_seed(seed)
610
+ out: list[torch.Tensor] = []
611
+ for _ in range(count):
612
+ vector = torch.randn(hidden, generator=generator)
613
+ out.append((vector / torch.linalg.vector_norm(vector)).to(device))
614
+ return out
615
+
616
+
617
+ def shuffled_label_direction(
618
+ desired: torch.Tensor,
619
+ undesired: torch.Tensor,
620
+ *,
621
+ method: str = "caa",
622
+ seed: int = 0,
623
+ ) -> torch.Tensor:
624
+ """Refit a direction after permuting which side is which.
625
+
626
+ If the pipeline finds a "working" direction from label noise, the method is
627
+ fitting the dataset's incidental structure and no result from it means
628
+ anything. This is the control that catches that.
629
+ """
630
+ generator = torch.Generator().manual_seed(seed)
631
+ stacked = torch.cat([desired, undesired], dim=0)
632
+ permutation = torch.randperm(len(stacked), generator=generator)
633
+ half = len(desired)
634
+ left = stacked[permutation[:half]]
635
+ right = stacked[permutation[half:]]
636
+ if method == "pca":
637
+ size = min(len(left), len(right))
638
+ return fit_pca(left[:size], right[:size])
639
+ return fit_caa(left, right)
640
+
641
+
642
+ class GenerationInjector:
643
+ """Injects during autoregressive generation, where the mask trick fails.
644
+
645
+ With a KV cache the first forward pass covers the whole prompt and every
646
+ later pass carries a single token, so "which positions am I steering" has to
647
+ be answered from the pass index rather than from a precomputed mask. Getting
648
+ this wrong is silent: a patch meant to steer only the prompt would go on
649
+ steering every generated token and the free-generation numbers would not
650
+ correspond to the scored configuration at all.
651
+ """
652
+
653
+ def __init__(self, direction: torch.Tensor, strength: float, site: str) -> None:
654
+ norm = torch.linalg.vector_norm(direction)
655
+ if float(norm) <= 0:
656
+ raise ValueError("direction has zero norm")
657
+ if site not in INJECTION_SITES:
658
+ raise ValueError(f"unknown injection site {site!r}")
659
+ self.vector = (direction / norm).detach()
660
+ self.strength = float(strength)
661
+ self.site = site
662
+ self.prompt_pass_done = False
663
+ self.applied = 0
664
+ self._handle: Any = None
665
+
666
+ def reset(self) -> None:
667
+ self.prompt_pass_done = False
668
+ self.applied = 0
669
+
670
+ def _hook(self, module: Any, args: Any, output: Any) -> Any:
671
+ hidden = output[0] if isinstance(output, tuple) else output
672
+ is_prompt_pass = not self.prompt_pass_done
673
+ self.prompt_pass_done = True
674
+
675
+ active = (
676
+ self.site == "all"
677
+ or (self.site == "prompt" and is_prompt_pass)
678
+ or (self.site == "continuation" and not is_prompt_pass)
679
+ )
680
+ if not active or self.strength == 0.0:
681
+ return output
682
+
683
+ hidden = hidden + self.strength * self.vector.to(hidden.device, hidden.dtype)
684
+ self.applied += 1
685
+ if isinstance(output, tuple):
686
+ return (hidden,) + tuple(output[1:])
687
+ return hidden
688
+
689
+ def attach(self, layer_module: Any) -> "GenerationInjector":
690
+ self._handle = layer_module.register_forward_hook(self._hook)
691
+ return self
692
+
693
+ def remove(self) -> None:
694
+ if self._handle is not None:
695
+ self._handle.remove()
696
+ self._handle = None
697
+
698
+
699
+ def residual_norm_percentiles(
700
+ activations: torch.Tensor, percentiles: Iterable[float] = (50, 90, 95, 99)
701
+ ) -> dict[str, float]:
702
+ """Percentiles of the residual-stream norm, for strength calibration.
703
+
704
+ Strength is expressed as a fraction of the activation magnitude the model
705
+ naturally carries at that layer, so an intervention stays on-manifold
706
+ instead of being an arbitrary large number that merely breaks the model.
707
+ """
708
+ norms = torch.linalg.vector_norm(activations.float(), dim=-1)
709
+ return {
710
+ f"p{int(p)}": float(torch.quantile(norms, p / 100.0).item()) for p in percentiles
711
+ }