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,324 @@
1
+ """Compiling research patches into self-contained runtime artifacts.
2
+
3
+ This is the bridge between the two halves of the project. A v0.1 research patch
4
+ says *"feature 727 of SAE smoke_v0, at strength 16"*; the runtime needs *"this
5
+ 1536-dimensional vector, at this coefficient"*. Compilation resolves the former
6
+ into the latter, once, so that no user ever downloads a 72 MB SAE to apply three
7
+ directions.
8
+
9
+ The arithmetic
10
+ --------------
11
+ An SAE decoder column lives in normalised space: the SAE was trained on
12
+ activations scaled so ``E[||x||] = sqrt(d_in)``. Injecting into the raw residual
13
+ stream divides by that ``input_scale``. So a research edit of strength ``s`` on
14
+ feature ``f`` compiles to::
15
+
16
+ vector = unit(W_dec[:, f]) / input_scale
17
+ coefficient = s
18
+
19
+ Folding ``1/input_scale`` into the *vector* rather than the coefficient means the
20
+ runtime needs no scale metadata at all -- the vector is already in raw residual
21
+ units, and a coefficient of 1.0 means exactly what the research patch meant by
22
+ strength 1.0.
23
+
24
+ Several edits on the same layer are emitted as separate interventions rather
25
+ than pre-summed, so a user can still see and reason about the individual
26
+ directions in ``brainpatch inspect``.
27
+
28
+ This module needs torch (to read an SAE checkpoint) and therefore lives behind
29
+ the ``research`` extra. Nothing in the runtime path imports it.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ from brainpatch.patch import tensors as ts
39
+ from brainpatch.patch.format import (
40
+ BaseModelSpec,
41
+ Intervention,
42
+ Manifest,
43
+ PatchFormatError,
44
+ )
45
+ from brainpatch.patch.loader import save_patch
46
+ from brainpatch.schemas.patch import BrainPatchSpec
47
+
48
+ #: Storage dtype for compiled vectors. fp16 halves the artifact with no
49
+ #: meaningful loss: these are directions whose magnitude is set by a coefficient
50
+ #: the user controls at runtime anyway.
51
+ DEFAULT_VECTOR_DTYPE = "F16"
52
+
53
+
54
+ class CompileError(RuntimeError):
55
+ """The research patch could not be resolved into runtime vectors."""
56
+
57
+
58
+ def compile_from_sae(
59
+ spec: BrainPatchSpec,
60
+ sae_checkpoint: str | os.PathLike[str],
61
+ output: str | os.PathLike[str],
62
+ *,
63
+ base_model: BaseModelSpec | None = None,
64
+ dtype: str = DEFAULT_VECTOR_DTYPE,
65
+ evidence_level: str | None = None,
66
+ compatibility: dict[str, dict[str, Any]] | None = None,
67
+ readme: str | None = None,
68
+ overwrite: bool = False,
69
+ injection_site: str = "all",
70
+ extra_provenance: dict[str, Any] | None = None,
71
+ ) -> Path:
72
+ """Materialise a v0.1 research patch into a ``.brainpatch`` artifact.
73
+
74
+ Parameters
75
+ ----------
76
+ spec:
77
+ The research patch, naming SAE feature IDs and strengths.
78
+ sae_checkpoint:
79
+ Path to the ``sae_latest.pt`` those IDs index into.
80
+ base_model:
81
+ Architecture facts. Defaults are taken from the SAE config, which
82
+ recorded the model it was fitted on.
83
+
84
+ Returns
85
+ -------
86
+ Path
87
+ The written artifact.
88
+ """
89
+ import torch
90
+
91
+ from brainpatch.research.ml.sae import TopKSAE
92
+
93
+ checkpoint_path = Path(sae_checkpoint)
94
+ if not checkpoint_path.is_file():
95
+ raise CompileError(f"SAE checkpoint not found: {checkpoint_path}")
96
+
97
+ checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
98
+ sae = TopKSAE.from_checkpoint(checkpoint, device="cpu")
99
+ config = sae.config
100
+
101
+ if spec.sae.d_sae != sae.d_sae:
102
+ raise CompileError(
103
+ f"patch expects a dictionary of size {spec.sae.d_sae} but the checkpoint "
104
+ f"has {sae.d_sae} -- these are different SAEs and feature IDs are not comparable"
105
+ )
106
+ if spec.sae.d_in != sae.d_in:
107
+ raise CompileError(
108
+ f"patch expects hidden size {spec.sae.d_in} but the SAE was trained on {sae.d_in}"
109
+ )
110
+
111
+ input_scale = spec.sae.input_scale or config.input_scale
112
+ if not input_scale:
113
+ raise CompileError(
114
+ "no input_scale is recorded in either the patch or the SAE checkpoint, so "
115
+ "the compiled vector would have no defined magnitude"
116
+ )
117
+
118
+ resolved_model = base_model or BaseModelSpec(
119
+ model_id=spec.base_model,
120
+ architecture=_architecture_for(spec.base_model),
121
+ hidden_size=sae.d_in,
122
+ num_layers=_num_layers_for(spec.base_model),
123
+ revision=spec.model_revision or (config.model_revision or None),
124
+ )
125
+ if resolved_model.num_layers <= 0:
126
+ raise CompileError(
127
+ f"could not determine the layer count for {resolved_model.model_id!r}; "
128
+ "pass base_model explicitly"
129
+ )
130
+
131
+ vectors: dict[str, ts.Tensor] = {}
132
+ interventions: list[Intervention] = []
133
+
134
+ for edit in spec.features:
135
+ if edit.mode != "add":
136
+ raise CompileError(
137
+ f"cannot compile intervention mode {edit.mode!r}: ablation depends on the "
138
+ "SAE encoder at runtime and has no fixed-vector representation"
139
+ )
140
+ direction = sae.feature_direction(edit.feature_id, normalize=True)
141
+ # Fold the scale into the vector so the runtime carries no SAE metadata.
142
+ raw = (direction / float(input_scale)).to(torch.float32).tolist()
143
+
144
+ key = f"f{edit.feature_id}"
145
+ vectors[key] = ts.vector(raw, dtype=dtype)
146
+ interventions.append(
147
+ Intervention(
148
+ layer=spec.sae.layer,
149
+ vector=key,
150
+ coefficient=float(edit.strength),
151
+ hook=spec.sae.hook or "residual_post",
152
+ id=f"sae-feature-{edit.feature_id}",
153
+ site=injection_site,
154
+ )
155
+ )
156
+
157
+ manifest = Manifest(
158
+ name=spec.name,
159
+ base_model=resolved_model,
160
+ interventions=interventions,
161
+ description=spec.description,
162
+ evidence_level=evidence_level or spec.evidence_level,
163
+ evaluation=dict(spec.evaluation),
164
+ compatibility=compatibility or {},
165
+ provenance={
166
+ "method": "sparse_autoencoder_decoder_direction",
167
+ "sae_reference": spec.sae.reference,
168
+ "sae_d_sae": spec.sae.d_sae,
169
+ "sae_layer": spec.sae.layer,
170
+ "sae_input_scale": float(input_scale),
171
+ "source_format_version": spec.format_version,
172
+ "source_feature_ids": [e.feature_id for e in spec.features],
173
+ "compiled_by": "brainpatch.patch.compiler",
174
+ "note": (
175
+ "Vectors are unit decoder columns divided by input_scale, so they are "
176
+ "already in raw residual-stream units. The runtime needs no SAE."
177
+ ),
178
+ "injection_site": injection_site,
179
+ **dict(extra_provenance or {}),
180
+ **dict(spec.metadata),
181
+ },
182
+ max_abs_strength=max(8.0, max(abs(e.strength) for e in spec.features) * 2),
183
+ default_strength=1.0,
184
+ license=spec.license,
185
+ authors=list(spec.authors),
186
+ schedule=spec.schedule,
187
+ )
188
+ manifest.validate()
189
+
190
+ return save_patch(manifest, vectors, output, readme=readme, overwrite=overwrite)
191
+
192
+
193
+ def compile_from_vectors(
194
+ name: str,
195
+ vectors: dict[str, list[float]],
196
+ interventions: list[Intervention],
197
+ base_model: BaseModelSpec,
198
+ output: str | os.PathLike[str],
199
+ *,
200
+ dtype: str = DEFAULT_VECTOR_DTYPE,
201
+ description: str = "",
202
+ evidence_level: str = "none",
203
+ provenance: dict[str, Any] | None = None,
204
+ evaluation: dict[str, Any] | None = None,
205
+ compatibility: dict[str, dict[str, Any]] | None = None,
206
+ readme: str | None = None,
207
+ overwrite: bool = False,
208
+ ) -> Path:
209
+ """Build an artifact from raw vectors, whatever method produced them.
210
+
211
+ The runtime does not care whether a direction came from an SAE, a difference
212
+ of means, PCA, or a learned controller -- this is the entry point for every
213
+ method that is not the SAE path.
214
+ """
215
+ tensor_map = {key: ts.vector(values, dtype=dtype) for key, values in vectors.items()}
216
+ manifest = Manifest(
217
+ name=name,
218
+ base_model=base_model,
219
+ interventions=interventions,
220
+ description=description,
221
+ evidence_level=evidence_level, # type: ignore[arg-type]
222
+ evaluation=dict(evaluation or {}),
223
+ compatibility=dict(compatibility or {}),
224
+ provenance=dict(provenance or {}),
225
+ )
226
+ manifest.validate()
227
+ return save_patch(manifest, tensor_map, output, readme=readme, overwrite=overwrite)
228
+
229
+
230
+ def _architecture_for(model_id: str) -> str:
231
+ """Read the architecture string from a model config, if reachable."""
232
+ try:
233
+ from transformers import AutoConfig
234
+
235
+ config = AutoConfig.from_pretrained(model_id)
236
+ archs = getattr(config, "architectures", None) or []
237
+ return archs[0] if archs else config.model_type
238
+ except Exception: # noqa: BLE001 - offline compile must still work
239
+ return ""
240
+
241
+
242
+ def _num_layers_for(model_id: str) -> int:
243
+ try:
244
+ from transformers import AutoConfig
245
+
246
+ config = AutoConfig.from_pretrained(model_id)
247
+ return int(getattr(config, "num_hidden_layers", 0))
248
+ except Exception: # noqa: BLE001
249
+ return 0
250
+
251
+
252
+ def export_llamacpp_control_vector(
253
+ patch_path: str | os.PathLike[str],
254
+ output: str | os.PathLike[str],
255
+ *,
256
+ strength: float = 1.0,
257
+ ) -> Path:
258
+ """Export a compiled patch as a llama.cpp control-vector GGUF.
259
+
260
+ llama.cpp's control-vector format is a GGUF holding one tensor per layer,
261
+ named ``direction.<layer>``, where layer indices are **1-based** -- unlike
262
+ BrainPatch's 0-based decoder-block indexing. Getting that mapping wrong
263
+ silently steers the wrong block, so it is done explicitly here and asserted
264
+ in the integration test.
265
+
266
+ Requires ``gguf``, which ships with llama.cpp's Python tooling.
267
+ """
268
+ from brainpatch.patch.loader import load_patch
269
+
270
+ try:
271
+ import gguf
272
+ except ModuleNotFoundError as exc: # pragma: no cover - optional tooling
273
+ raise CompileError(
274
+ "exporting a llama.cpp control vector needs the 'gguf' package.\n"
275
+ " pip install gguf"
276
+ ) from exc
277
+
278
+ import numpy as np
279
+
280
+ loaded = load_patch(patch_path)
281
+ hidden = loaded.manifest.base_model.hidden_size
282
+
283
+ # Sum every intervention that targets the same layer: llama.cpp applies one
284
+ # direction per layer, so multi-vector layers must be combined at export.
285
+ per_layer: dict[int, list[float]] = {}
286
+ for intervention in loaded.manifest.interventions:
287
+ vector = loaded.vector_for(intervention.vector)
288
+ scaled = [v * intervention.coefficient * strength for v in vector.data]
289
+ acc = per_layer.setdefault(intervention.layer, [0.0] * hidden)
290
+ for i, value in enumerate(scaled):
291
+ acc[i] += value
292
+
293
+ out = Path(output)
294
+ if out.suffix != ".gguf":
295
+ out = out.with_suffix(".gguf")
296
+ out.parent.mkdir(parents=True, exist_ok=True)
297
+
298
+ writer = gguf.GGUFWriter(str(out), arch="controlvector")
299
+ writer.add_string("controlvector.model_hint", _model_hint(loaded.manifest.base_model.architecture))
300
+ writer.add_uint32("controlvector.layer_count", len(per_layer))
301
+ for layer, values in sorted(per_layer.items()):
302
+ # 0-based BrainPatch layer -> 1-based llama.cpp direction index.
303
+ writer.add_tensor(f"direction.{layer + 1}", np.array(values, dtype=np.float32))
304
+ writer.write_header_to_file()
305
+ writer.write_kv_data_to_file()
306
+ writer.write_tensors_to_file()
307
+ writer.close()
308
+ return out
309
+
310
+
311
+ def _model_hint(architecture: str) -> str:
312
+ """llama.cpp's short architecture hint for control vectors."""
313
+ lowered = (architecture or "").lower()
314
+ for needle, hint in (
315
+ ("qwen2", "qwen2"),
316
+ ("qwen", "qwen2"),
317
+ ("llama", "llama"),
318
+ ("mistral", "llama"),
319
+ ("gemma", "gemma"),
320
+ ("phi", "phi2"),
321
+ ):
322
+ if needle in lowered:
323
+ return hint
324
+ return "unknown"