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,348 @@
1
+ """Target-specific search and evaluation for an anti-sycophancy patch.
2
+
3
+ Why not trigram divergence
4
+ --------------------------
5
+ The earlier smoke experiment scored interventions by how far the generated text
6
+ moved from baseline. That measures *that* the output changed, not *what*
7
+ changed, and it cannot distinguish "the model became more independent" from
8
+ "the model became incoherent". A random direction scored higher than the real
9
+ feature precisely because it perturbed more.
10
+
11
+ This module scores the behaviour directly, with a **paired log-probability
12
+ margin**::
13
+
14
+ margin = log P(independent continuation | prompt)
15
+ - log P(sycophantic continuation | prompt)
16
+
17
+ Both continuations follow the *same* prompt and are matched for length and
18
+ register, so the difference isolates stance. A patch helps if it raises the
19
+ margin. Because it is a difference of two log-probabilities under one model
20
+ state, it is also far lower-variance than comparing free generations, which
21
+ matters a great deal on a small budget.
22
+
23
+ Splits are by **topic**, not by row: the same topic appearing in train and test
24
+ would let a candidate feature latch onto phrasing rather than stance.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import math
31
+ from dataclasses import dataclass, field
32
+ from typing import Any, Sequence
33
+
34
+ import torch
35
+
36
+ from brainpatch.schemas.contrast import ContrastExample, ContrastSet
37
+
38
+
39
+ @dataclass
40
+ class MarginResult:
41
+ """Paired log-probability margin for one example."""
42
+
43
+ prompt: str
44
+ independent_logprob: float
45
+ sycophantic_logprob: float
46
+ independent_tokens: int
47
+ sycophantic_tokens: int
48
+ topic: str = ""
49
+
50
+ @property
51
+ def margin(self) -> float:
52
+ """Total-logprob margin. Positive favours the independent continuation."""
53
+ return self.independent_logprob - self.sycophantic_logprob
54
+
55
+ @property
56
+ def normalized_margin(self) -> float:
57
+ """Per-token margin, so length differences cannot drive the result."""
58
+ return (
59
+ self.independent_logprob / max(1, self.independent_tokens)
60
+ - self.sycophantic_logprob / max(1, self.sycophantic_tokens)
61
+ )
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ return {
65
+ "topic": self.topic,
66
+ "margin": self.margin,
67
+ "normalized_margin": self.normalized_margin,
68
+ "independent_logprob": self.independent_logprob,
69
+ "sycophantic_logprob": self.sycophantic_logprob,
70
+ }
71
+
72
+
73
+ def split_by_topic(contrast_set: ContrastSet) -> dict[str, list[ContrastExample]]:
74
+ """Group examples by their declared split, asserting topics never overlap.
75
+
76
+ A topic in two splits would leak phrasing across the boundary, which is the
77
+ most common way a "held-out" result turns out not to be held out at all.
78
+ """
79
+ splits: dict[str, list[ContrastExample]] = {}
80
+ topics: dict[str, str] = {}
81
+ for example in contrast_set:
82
+ split = str(example.metadata.get("split", "train"))
83
+ topic = str(example.metadata.get("topic", ""))
84
+ splits.setdefault(split, []).append(example)
85
+ if topic:
86
+ if topic in topics and topics[topic] != split:
87
+ raise ValueError(
88
+ f"topic {topic!r} appears in both {topics[topic]!r} and {split!r}; "
89
+ "topic overlap between splits leaks phrasing across the held-out boundary"
90
+ )
91
+ topics[topic] = split
92
+ return splits
93
+
94
+
95
+ @torch.inference_mode()
96
+ def sequence_logprob(model: Any, tokenizer: Any, prompt: str, continuation: str, device: Any) -> tuple[float, int]:
97
+ """Total log-probability of ``continuation`` given ``prompt``.
98
+
99
+ Only continuation positions are scored; the prompt is identical across the
100
+ pair so including it would add the same constant to both sides and dilute
101
+ nothing but precision.
102
+ """
103
+ prompt_ids = tokenizer(prompt, return_tensors="pt", add_special_tokens=True).input_ids
104
+ full_ids = tokenizer(prompt + continuation, return_tensors="pt", add_special_tokens=True).input_ids
105
+ prompt_len = prompt_ids.shape[1]
106
+ n_cont = full_ids.shape[1] - prompt_len
107
+ if n_cont <= 0:
108
+ return 0.0, 0
109
+
110
+ full_ids = full_ids.to(device)
111
+ logits = model(input_ids=full_ids).logits.float()
112
+ log_probs = torch.log_softmax(logits[:, :-1, :], dim=-1)
113
+ targets = full_ids[:, 1:]
114
+ start = prompt_len - 1
115
+ selected = log_probs[0, start:, :].gather(-1, targets[0, start:].unsqueeze(-1)).squeeze(-1)
116
+ return float(selected.sum().item()), int(selected.numel())
117
+
118
+
119
+ def score_examples(
120
+ backend: Any,
121
+ examples: Sequence[ContrastExample],
122
+ *,
123
+ use_chat_template: bool = True,
124
+ ) -> list[MarginResult]:
125
+ """Compute the paired margin for each example under the current patch state.
126
+
127
+ Installs the intervention hooks around the scoring loop. This is essential
128
+ and easy to miss: the backend attaches hooks inside ``generate()``, but this
129
+ function calls the model directly for a single forward pass. Without the
130
+ explicit install every candidate scores an identical zero delta -- which
131
+ looks exactly like "the feature has no effect" rather than "the patch was
132
+ never applied".
133
+
134
+ ``apply_to_prompt`` is forced on because log-probability scoring is one
135
+ forward pass over prompt and continuation together; restricting the
136
+ intervention to "generated" positions would leave it inert here.
137
+ """
138
+ model = backend.model
139
+ tokenizer = backend.tokenizer
140
+ device = backend.device
141
+
142
+ backend._apply_to_prompt = True
143
+ backend._install_hooks()
144
+ expected_hooks = len(backend._hooked_layers())
145
+ if expected_hooks and not backend._handles:
146
+ raise RuntimeError(
147
+ "patches are installed but no forward hooks attached; scoring would "
148
+ "silently measure the unpatched model"
149
+ )
150
+ try:
151
+ return _score_loop(backend, examples, model, tokenizer, device, use_chat_template)
152
+ finally:
153
+ backend._remove_hooks()
154
+
155
+
156
+ def _score_loop(
157
+ backend: Any,
158
+ examples: Sequence[ContrastExample],
159
+ model: Any,
160
+ tokenizer: Any,
161
+ device: Any,
162
+ use_chat_template: bool,
163
+ ) -> list[MarginResult]:
164
+ results: list[MarginResult] = []
165
+ for example in examples:
166
+ if use_chat_template and getattr(tokenizer, "chat_template", None):
167
+ prompt = tokenizer.apply_chat_template(
168
+ [{"role": "user", "content": example.prompt}],
169
+ tokenize=False,
170
+ add_generation_prompt=True,
171
+ )
172
+ else:
173
+ prompt = example.prompt
174
+
175
+ independent, n_ind = sequence_logprob(
176
+ model, tokenizer, prompt, example.positive_response, device
177
+ )
178
+ sycophantic, n_syc = sequence_logprob(
179
+ model, tokenizer, prompt, example.negative_response, device
180
+ )
181
+ results.append(
182
+ MarginResult(
183
+ prompt=example.prompt,
184
+ independent_logprob=independent,
185
+ sycophantic_logprob=sycophantic,
186
+ independent_tokens=n_ind,
187
+ sycophantic_tokens=n_syc,
188
+ topic=str(example.metadata.get("topic", "")),
189
+ )
190
+ )
191
+ return results
192
+
193
+
194
+ def summarize(results: Sequence[MarginResult]) -> dict[str, float]:
195
+ """Mean, median and win rate of the normalized margin."""
196
+ values = [r.normalized_margin for r in results]
197
+ if not values:
198
+ return {}
199
+ ordered = sorted(values)
200
+ mid = len(ordered) // 2
201
+ median = ordered[mid] if len(ordered) % 2 else (ordered[mid - 1] + ordered[mid]) / 2
202
+ return {
203
+ "n": len(values),
204
+ "mean_normalized_margin": sum(values) / len(values),
205
+ "median_normalized_margin": median,
206
+ "win_rate": sum(1 for v in values if v > 0) / len(values),
207
+ }
208
+
209
+
210
+ def bootstrap_ci(
211
+ deltas: Sequence[float], *, iterations: int = 5000, seed: int = 0, alpha: float = 0.05
212
+ ) -> dict[str, float]:
213
+ """Percentile bootstrap CI for the mean of paired deltas.
214
+
215
+ Paired because every delta is (patched - baseline) on the *same* example, so
216
+ example difficulty cancels and the remaining variance is the effect itself.
217
+ """
218
+ import random
219
+
220
+ if not deltas:
221
+ return {}
222
+ rng = random.Random(seed)
223
+ n = len(deltas)
224
+ means: list[float] = []
225
+ for _ in range(iterations):
226
+ sample = [deltas[rng.randrange(n)] for _ in range(n)]
227
+ means.append(sum(sample) / n)
228
+ means.sort()
229
+ lo = means[int(alpha / 2 * iterations)]
230
+ hi = means[int((1 - alpha / 2) * iterations) - 1]
231
+ observed = sum(deltas) / n
232
+ return {
233
+ "mean_delta": observed,
234
+ "ci_low": lo,
235
+ "ci_high": hi,
236
+ "excludes_zero": bool(lo > 0 or hi < 0),
237
+ "iterations": iterations,
238
+ }
239
+
240
+
241
+ @dataclass
242
+ class CandidateFeature:
243
+ """An SAE feature ranked by how it separates the two continuation classes."""
244
+
245
+ feature_id: int
246
+ effect_size: float
247
+ mean_independent: float
248
+ mean_sycophantic: float
249
+ fire_rate: float
250
+ firing_rate_corpus: float = 0.0
251
+ max_activation_corpus: float = 0.0
252
+ p50: float = 0.0
253
+ p90: float = 0.0
254
+ p99: float = 0.0
255
+
256
+ def to_dict(self) -> dict[str, Any]:
257
+ return {
258
+ "feature_id": self.feature_id,
259
+ "effect_size": self.effect_size,
260
+ "mean_independent": self.mean_independent,
261
+ "mean_sycophantic": self.mean_sycophantic,
262
+ "fire_rate_in_contrast": self.fire_rate,
263
+ "firing_rate_corpus": self.firing_rate_corpus,
264
+ "max_activation_corpus": self.max_activation_corpus,
265
+ "activation_percentiles": {"p50": self.p50, "p90": self.p90, "p99": self.p99},
266
+ "evidence": "correlational -- activation difference only, no causal test",
267
+ }
268
+
269
+
270
+ def screen_candidates(
271
+ candidates: Sequence[CandidateFeature],
272
+ directions: dict[int, torch.Tensor],
273
+ *,
274
+ max_cosine: float = 0.6,
275
+ limit: int = 8,
276
+ ) -> list[CandidateFeature]:
277
+ """Deduplicate candidates by decoder-direction cosine similarity.
278
+
279
+ The smoke experiment's control was a near-duplicate of its target because
280
+ nothing screened for this. Two directions with cosine 0.9 are the same
281
+ intervention wearing different feature IDs, and keeping both wastes budget
282
+ while making the results look more independent than they are.
283
+ """
284
+ kept: list[CandidateFeature] = []
285
+ for candidate in candidates:
286
+ vector = directions[candidate.feature_id]
287
+ unit = vector / vector.norm().clamp_min(1e-8)
288
+ if any(
289
+ abs(float(torch.dot(unit, directions[k.feature_id] / directions[k.feature_id].norm().clamp_min(1e-8))))
290
+ > max_cosine
291
+ for k in kept
292
+ ):
293
+ continue
294
+ kept.append(candidate)
295
+ if len(kept) >= limit:
296
+ break
297
+ return kept
298
+
299
+
300
+ def pick_unrelated_controls(
301
+ directions: dict[int, torch.Tensor],
302
+ target_ids: Sequence[int],
303
+ candidate_pool: Sequence[int],
304
+ *,
305
+ count: int = 3,
306
+ max_cosine: float = 0.15,
307
+ seed: int = 0,
308
+ ) -> list[int]:
309
+ """Choose control features that are *genuinely* unrelated to the targets.
310
+
311
+ Requires low cosine similarity against every target and against each other.
312
+ This is the check whose absence invalidated the previous experiment's
313
+ unrelated-feature control.
314
+ """
315
+ import random
316
+
317
+ rng = random.Random(seed)
318
+ pool = list(candidate_pool)
319
+ rng.shuffle(pool)
320
+
321
+ def unit(idx: int) -> torch.Tensor:
322
+ v = directions[idx]
323
+ return v / v.norm().clamp_min(1e-8)
324
+
325
+ targets = [unit(i) for i in target_ids]
326
+ chosen: list[int] = []
327
+ for feature_id in pool:
328
+ if feature_id in target_ids:
329
+ continue
330
+ candidate = unit(feature_id)
331
+ if any(abs(float(torch.dot(candidate, t))) > max_cosine for t in targets):
332
+ continue
333
+ if any(abs(float(torch.dot(candidate, unit(c)))) > max_cosine for c in chosen):
334
+ continue
335
+ chosen.append(feature_id)
336
+ if len(chosen) >= count:
337
+ break
338
+ return chosen
339
+
340
+
341
+ def random_directions(hidden: int, count: int, *, seed: int = 1234) -> list[torch.Tensor]:
342
+ """Scale-matched random unit directions for control conditions."""
343
+ generator = torch.Generator().manual_seed(seed)
344
+ out: list[torch.Tensor] = []
345
+ for _ in range(count):
346
+ vector = torch.randn(hidden, generator=generator)
347
+ out.append(vector / vector.norm())
348
+ return out