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.
- brainpatch/__init__.py +92 -0
- brainpatch/backends/__init__.py +19 -0
- brainpatch/backends/llamacpp.py +383 -0
- brainpatch/backends/mlx_backend.py +213 -0
- brainpatch/backends/transformers_backend.py +473 -0
- brainpatch/backends/vllm_backend.py +299 -0
- brainpatch/backends/vllm_worker.py +129 -0
- brainpatch/cli.py +825 -0
- brainpatch/config.py +245 -0
- brainpatch/datasets/__init__.py +20 -0
- brainpatch/datasets/contrast_sets.py +64 -0
- brainpatch/evaluation/__init__.py +28 -0
- brainpatch/evaluation/metrics.py +223 -0
- brainpatch/patch/__init__.py +64 -0
- brainpatch/patch/compiler.py +324 -0
- brainpatch/patch/format.py +489 -0
- brainpatch/patch/loader.py +312 -0
- brainpatch/patch/registry.py +300 -0
- brainpatch/patch/tensors.py +236 -0
- brainpatch/patch/validation.py +157 -0
- brainpatch/paths.py +184 -0
- brainpatch/py.typed +0 -0
- brainpatch/research/__init__.py +16 -0
- brainpatch/research/antisycophancy.py +348 -0
- brainpatch/research/behaviour_eval.py +711 -0
- brainpatch/research/generation_eval.py +346 -0
- brainpatch/research/ml/__init__.py +35 -0
- brainpatch/research/ml/activation_store.py +232 -0
- brainpatch/research/ml/causal.py +386 -0
- brainpatch/research/ml/corpus.py +165 -0
- brainpatch/research/ml/evaluation.py +188 -0
- brainpatch/research/ml/extraction.py +464 -0
- brainpatch/research/ml/feature_analysis.py +317 -0
- brainpatch/research/ml/generation.py +109 -0
- brainpatch/research/ml/hooks.py +183 -0
- brainpatch/research/ml/intervention.py +274 -0
- brainpatch/research/ml/model.py +219 -0
- brainpatch/research/ml/patch_search.py +337 -0
- brainpatch/research/ml/runtime.py +343 -0
- brainpatch/research/ml/sae.py +383 -0
- brainpatch/research/ml/training.py +376 -0
- brainpatch/research/stance_rubric.py +170 -0
- brainpatch/research/sycophancy_data.py +982 -0
- brainpatch/research/sycophancy_data_r1.py +1701 -0
- brainpatch/research/sycophancy_data_v2.py +1649 -0
- brainpatch/research/sycophancy_data_v3.py +2288 -0
- brainpatch/research/sycophancy_v2_build.py +362 -0
- brainpatch/research/sycophancy_v3_build.py +188 -0
- brainpatch/research/utility_probe.py +139 -0
- brainpatch/runtime/__init__.py +50 -0
- brainpatch/runtime/auto.py +157 -0
- brainpatch/runtime/base.py +311 -0
- brainpatch/runtime/capabilities.py +96 -0
- brainpatch/runtime/model.py +260 -0
- brainpatch/runtime/scheduling.py +13 -0
- brainpatch/schemas/__init__.py +35 -0
- brainpatch/schemas/contrast.py +161 -0
- brainpatch/schemas/feature.py +193 -0
- brainpatch/schemas/manifest.py +167 -0
- brainpatch/schemas/patch.py +379 -0
- brainpatch/schemas/patch_io.py +88 -0
- brainpatch/schemas/sae.py +146 -0
- brainpatch/server/__init__.py +11 -0
- brainpatch/server/app.py +269 -0
- brainpatch/steering/__init__.py +13 -0
- brainpatch/steering/plan.py +177 -0
- brainpatch/steering/schedule.py +138 -0
- brainpatch/ui/__init__.py +11 -0
- brainpatch/ui/app.py +201 -0
- brainpatch/verify/__init__.py +66 -0
- brainpatch/verify/behavioural.py +156 -0
- brainpatch/verify/checks.py +204 -0
- brainpatch/verify/corruptions.py +335 -0
- brainpatch/verify/report.py +133 -0
- brainpatch/verify/vectors.py +95 -0
- brainpatch/verify/workflow.py +331 -0
- brainpatch-1.2.0.dist-info/METADATA +556 -0
- brainpatch-1.2.0.dist-info/RECORD +82 -0
- brainpatch-1.2.0.dist-info/WHEEL +5 -0
- brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
- brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
- 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"
|