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,299 @@
1
+ """vLLM backend.
2
+
3
+ How the intervention reaches vLLM
4
+ ---------------------------------
5
+ vLLM's V1 engine runs the model in a **separate worker process**, so reaching it
6
+ by walking attributes off the ``LLM`` object does not work -- the model simply
7
+ is not in the caller's address space. The supported way in is
8
+ ``worker_extension_cls`` plus ``LLM.collective_rpc``: vLLM mixes
9
+ :class:`~brainpatch.backends.vllm_worker.BrainPatchWorkerExtension` into every
10
+ worker, and the RPC then invokes its methods **by name**.
11
+
12
+ Calling by name matters. V1's RPC channel serializes with msgpack and refuses to
13
+ ship a callable, suggesting ``VLLM_ALLOW_INSECURE_SERIALIZATION=1`` instead --
14
+ which would turn the engine's control channel into an arbitrary-code path for
15
+ our convenience. The extension class avoids that entirely.
16
+
17
+ The consequence is that the intervention genuinely runs inside vLLM's forward
18
+ pass, under its scheduler, batching and KV cache. There is no shadow
19
+ Transformers model anywhere in this file.
20
+
21
+ The RPC payload is a plain ``{layer: [floats]}`` map of *already-scaled* deltas.
22
+ That suffices precisely because this backend supports neither token schedules
23
+ nor per-request strength, so the delta is constant for a whole forward pass.
24
+
25
+ Request isolation
26
+ -----------------
27
+ Patch state is fixed while serving. With continuous batching one forward pass
28
+ serves many sequences, so a per-request coefficient would alter *other users'*
29
+ output; the backend therefore refuses to mutate patches mid-serve rather than
30
+ offering an unsafe knob. Two concurrent requests provably see identical model
31
+ behaviour, which is what the integration test checks.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import threading
37
+ from typing import Any, Iterator
38
+
39
+ from brainpatch.patch.validation import ModelDescriptor
40
+ from brainpatch.runtime.base import BrainPatchBackend, GenerationConfig
41
+ from brainpatch.runtime.capabilities import Capabilities
42
+
43
+ #: Import path vLLM loads into each worker process.
44
+ WORKER_EXTENSION = "brainpatch.backends.vllm_worker.BrainPatchWorkerExtension"
45
+
46
+
47
+ class VLLMBackend(BrainPatchBackend):
48
+ """Apply BrainPatches inside vLLM's inference path."""
49
+
50
+ name = "vllm"
51
+
52
+ def __init__(self) -> None:
53
+ super().__init__()
54
+ self.llm: Any = None
55
+ self.model_id: str = ""
56
+ self.revision: str | None = None
57
+ self.vllm_version: str = ""
58
+ self._geometry: dict[str, Any] = {}
59
+ self._last_rpc: list[dict[str, Any]] = []
60
+ self._lock = threading.RLock()
61
+ self._serving = False
62
+
63
+ # -- availability ----------------------------------------------------------
64
+
65
+ @classmethod
66
+ def is_available(cls) -> tuple[bool, str]:
67
+ try:
68
+ import vllm
69
+ except ModuleNotFoundError:
70
+ return False, "vLLM not installed -- pip install 'brainpatch[vllm]'"
71
+ try:
72
+ import torch
73
+
74
+ if not torch.cuda.is_available():
75
+ return False, f"vLLM {vllm.__version__} installed but no CUDA device is visible"
76
+ return True, f"vLLM {vllm.__version__}, CUDA: {torch.cuda.get_device_name(0)}"
77
+ except Exception as exc: # noqa: BLE001
78
+ return False, f"vLLM present but unusable: {exc}"
79
+
80
+ @classmethod
81
+ def capabilities(cls) -> Capabilities:
82
+ return Capabilities(
83
+ name=cls.name,
84
+ static_intervention=True,
85
+ dynamic_schedule=False,
86
+ multiple_patches=True,
87
+ streaming=False,
88
+ cpu=False,
89
+ cuda=True,
90
+ server=True,
91
+ concurrent_requests=True,
92
+ per_request_strength=False,
93
+ quantization=(),
94
+ notes={
95
+ "dynamic_schedule": (
96
+ "Continuous batching means one forward pass serves sequences at "
97
+ "different generation positions, so a single token index is not "
98
+ "well defined. Use the transformers backend for schedules."
99
+ ),
100
+ "per_request_strength": (
101
+ "Would require per-sequence scaling inside a batched forward pass; "
102
+ "vLLM exposes no supported way to attribute rows of a batch to "
103
+ "requests, so offering it would corrupt other users' output."
104
+ ),
105
+ "concurrent_requests": (
106
+ "Safe because patch state is immutable while serving; mutation "
107
+ "raises if attempted mid-serve."
108
+ ),
109
+ "cpu": "This backend requires a CUDA device.",
110
+ },
111
+ )
112
+
113
+ # -- model -----------------------------------------------------------------
114
+
115
+ def load_model(
116
+ self,
117
+ model: str,
118
+ *,
119
+ revision: str | None = None,
120
+ dtype: str = "auto",
121
+ gpu_memory_utilization: float = 0.80,
122
+ max_model_len: int | None = 2048,
123
+ enforce_eager: bool = True,
124
+ **kwargs: Any,
125
+ ) -> None:
126
+ """Load through vLLM.
127
+
128
+ ``enforce_eager=True`` by default: CUDA graph capture replays a recorded
129
+ graph, and a Python forward hook registered afterwards would not
130
+ participate. Eager costs throughput and is what makes the intervention
131
+ actually execute. Do not disable it without re-verifying that hooks run.
132
+ """
133
+ import vllm
134
+ from vllm import LLM
135
+
136
+ self.vllm_version = vllm.__version__
137
+ # Supported extension point: vLLM mixes this class into each worker, so
138
+ # collective_rpc can call its methods BY NAME with msgpack-safe args.
139
+ # Passing a callable instead would require
140
+ # VLLM_ALLOW_INSECURE_SERIALIZATION=1, which we deliberately do not use.
141
+ self.llm = LLM(
142
+ model=model,
143
+ worker_extension_cls=WORKER_EXTENSION,
144
+ revision=revision,
145
+ dtype=dtype,
146
+ gpu_memory_utilization=gpu_memory_utilization,
147
+ max_model_len=max_model_len,
148
+ enforce_eager=enforce_eager,
149
+ **kwargs,
150
+ )
151
+ self.model_id = model
152
+ self.revision = revision
153
+ self._geometry = self._rpc("bp_probe")[0]
154
+
155
+ def _rpc(self, method: str, *args: Any) -> list[dict[str, Any]]:
156
+ """Call a worker-extension method by name in every vLLM worker."""
157
+ if self.llm is None:
158
+ raise RuntimeError("no model loaded; call load_model() first")
159
+ rpc = getattr(self.llm, "collective_rpc", None)
160
+ if rpc is None:
161
+ raise RuntimeError(
162
+ f"vLLM {self.vllm_version} has no LLM.collective_rpc, which this "
163
+ "backend needs to reach the worker process. Supported: vLLM with "
164
+ "collective_rpc and worker_extension_cls (verified on 0.11.0)."
165
+ )
166
+ return list(rpc(method, args=args) if args else rpc(method))
167
+
168
+ def describe_model(self) -> ModelDescriptor:
169
+ if self.llm is None:
170
+ raise RuntimeError("no model loaded; call load_model() first")
171
+ geometry = self._geometry
172
+ archs = geometry.get("architectures") or []
173
+ return ModelDescriptor(
174
+ model_id=self.model_id,
175
+ hidden_size=int(geometry.get("hidden_size", 0)),
176
+ num_layers=int(geometry.get("num_layers", 0)),
177
+ architecture=archs[0] if archs else geometry.get("model_class", ""),
178
+ revision=self.revision,
179
+ )
180
+
181
+ # -- intervention ----------------------------------------------------------
182
+
183
+ def _deltas_by_layer(self) -> dict[int, list[float]]:
184
+ """Collapse all enabled patches into one already-scaled vector per layer."""
185
+ hidden = int(self._geometry.get("hidden_size", 0))
186
+ deltas: dict[int, list[float]] = {}
187
+ for edit in self.resolve_edits(0):
188
+ values = self.vector_values(edit.patch_name, edit.vector_key)
189
+ acc = deltas.setdefault(edit.layer, [0.0] * (hidden or len(values)))
190
+ for i, value in enumerate(values):
191
+ acc[i] += value * edit.coefficient
192
+ return deltas
193
+
194
+ def _on_patches_changed(self) -> None:
195
+ if self._serving:
196
+ raise RuntimeError(
197
+ "refusing to mutate patch state while the vLLM server is running: "
198
+ "in-flight batched requests would observe an inconsistent model. "
199
+ "Restart the server to change patches."
200
+ )
201
+ if self.llm is None:
202
+ return
203
+ self._last_rpc = self._rpc("bp_apply_deltas", self._deltas_by_layer())
204
+
205
+ @property
206
+ def last_hook_report(self) -> list[dict[str, Any]]:
207
+ """What the workers reported after the last hook installation.
208
+
209
+ Exposed so an integration test can *prove* the hooks landed inside vLLM
210
+ rather than inferring it from output changes.
211
+ """
212
+ return list(self._last_rpc)
213
+
214
+ def worker_state(self) -> list[dict[str, Any]]:
215
+ """Live probe of every worker, including active hook count."""
216
+ return self._rpc("bp_probe")
217
+
218
+ # -- generation ------------------------------------------------------------
219
+
220
+ def generate(self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any) -> str:
221
+ return self.generate_batch([prompt], config, **kwargs)[0]
222
+
223
+ def generate_batch(
224
+ self,
225
+ prompts: list[str],
226
+ config: GenerationConfig | None = None,
227
+ *,
228
+ use_chat_template: bool = True,
229
+ system: str | None = None,
230
+ **kwargs: Any,
231
+ ) -> list[str]:
232
+ """Batched generation -- the reason to use vLLM at all.
233
+
234
+ ``system`` and ``use_chat_template`` are part of the cross-backend
235
+ generate() contract, so they are consumed here rather than forwarded:
236
+ passing them through to ``llm.generate`` raises a TypeError, which is
237
+ how the OpenAI server first failed against this backend.
238
+ """
239
+ from vllm import SamplingParams
240
+
241
+ # Only vLLM's own arguments may reach it.
242
+ kwargs.pop("apply_to_prompt", None)
243
+
244
+ if self.llm is None:
245
+ raise RuntimeError("no model loaded; call load_model() first")
246
+ cfg = config or GenerationConfig()
247
+ params = SamplingParams(
248
+ max_tokens=cfg.max_new_tokens,
249
+ temperature=cfg.temperature,
250
+ top_p=cfg.top_p,
251
+ top_k=cfg.top_k if cfg.top_k > 0 else -1,
252
+ repetition_penalty=cfg.repetition_penalty,
253
+ seed=cfg.seed if cfg.do_sample else None,
254
+ stop=cfg.stop or None,
255
+ )
256
+ rendered = (
257
+ [self._render(p, system) for p in prompts] if use_chat_template else list(prompts)
258
+ )
259
+ with self._lock:
260
+ outputs = self.llm.generate(rendered, params, **kwargs)
261
+ return [o.outputs[0].text for o in outputs]
262
+
263
+ def _render(self, prompt: str, system: str | None = None) -> str:
264
+ tokenizer = self.llm.get_tokenizer()
265
+ if getattr(tokenizer, "chat_template", None) is None:
266
+ return prompt
267
+ messages = []
268
+ if system:
269
+ messages.append({"role": "system", "content": system})
270
+ messages.append({"role": "user", "content": prompt})
271
+ return tokenizer.apply_chat_template(
272
+ messages, tokenize=False, add_generation_prompt=True
273
+ )
274
+
275
+ def stream(
276
+ self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
277
+ ) -> Iterator[str]:
278
+ self.capabilities().require("streaming")
279
+ yield "" # pragma: no cover - unreachable; require() raises
280
+
281
+ # -- serving ---------------------------------------------------------------
282
+
283
+ def begin_serving(self) -> None:
284
+ """Freeze patch state for the lifetime of a server."""
285
+ self._serving = True
286
+
287
+ def end_serving(self) -> None:
288
+ self._serving = False
289
+
290
+ def unload(self) -> None:
291
+ if self.llm is not None:
292
+ try:
293
+ self._rpc("bp_apply_deltas", {})
294
+ except Exception: # noqa: BLE001 - teardown must not mask a real error
295
+ pass
296
+ self.llm = None
297
+
298
+
299
+ BACKEND = VLLMBackend
@@ -0,0 +1,129 @@
1
+ """Worker-side extension that installs BrainPatch hooks inside vLLM.
2
+
3
+ vLLM's V1 engine runs the model in a separate process, and its RPC channel
4
+ serializes with msgpack -- it will **not** ship a callable, and says so:
5
+
6
+ TypeError: Object of type <class 'function'> is not serializable
7
+ Set VLLM_ALLOW_INSECURE_SERIALIZATION=1 to allow fallback to pickle
8
+
9
+ Enabling pickle would work and is the wrong answer: it turns the engine's
10
+ control channel into an arbitrary-code path for the sake of our convenience.
11
+ The supported mechanism is ``worker_extension_cls`` -- vLLM mixes this class
12
+ into the worker, and ``collective_rpc("method_name", args=...)`` then calls it
13
+ by **name** with plain msgpack-serializable arguments.
14
+
15
+ So the payload here is a ``{layer_index: [floats]}`` map of already-scaled
16
+ deltas. That suffices because this backend supports neither token schedules nor
17
+ per-request strength, so the delta is constant across a forward pass -- which is
18
+ also exactly what makes concurrent batching safe.
19
+
20
+ Method names are prefixed ``bp_`` to avoid colliding with vLLM's own worker API.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ _LAYER_PATHS = ("model.layers", "layers", "transformer.h", "model.decoder.layers")
28
+
29
+ #: Set on the worker-side model so repeated calls replace rather than stack hooks.
30
+ _HOOK_ATTR = "_brainpatch_handles"
31
+
32
+
33
+ class BrainPatchWorkerExtension:
34
+ """Mixed into the vLLM worker; ``self`` is the worker instance."""
35
+
36
+ def _bp_model(self) -> Any:
37
+ for path in ("model_runner.model", "worker.model_runner.model"):
38
+ node: Any = self
39
+ for part in path.split("."):
40
+ node = getattr(node, part, None)
41
+ if node is None:
42
+ break
43
+ if node is not None:
44
+ return node
45
+ raise RuntimeError(
46
+ f"could not locate the model on vLLM worker {type(self).__name__}"
47
+ )
48
+
49
+ def _bp_layers(self, model: Any) -> Any:
50
+ import torch.nn as nn
51
+
52
+ for path in _LAYER_PATHS:
53
+ node: Any = model
54
+ for part in path.split("."):
55
+ node = getattr(node, part, None)
56
+ if node is None:
57
+ break
58
+ if isinstance(node, nn.ModuleList) and len(node) > 0:
59
+ return node
60
+ raise RuntimeError(f"could not locate decoder blocks on {type(model).__name__}")
61
+
62
+ def bp_probe(self) -> dict[str, Any]:
63
+ """Report worker-side model geometry and live hook count."""
64
+ model = self._bp_model()
65
+ layers = self._bp_layers(model)
66
+ config = getattr(model, "config", None)
67
+ return {
68
+ "model_class": type(model).__name__,
69
+ "num_layers": len(layers),
70
+ "hidden_size": int(getattr(config, "hidden_size", 0)) if config else 0,
71
+ "architectures": list(getattr(config, "architectures", []) or []) if config else [],
72
+ "active_hooks": len(getattr(model, _HOOK_ATTR, [])),
73
+ }
74
+
75
+ def bp_apply_deltas(self, deltas: dict[int, list[float]]) -> dict[str, Any]:
76
+ """Install (or replace) residual-stream hooks. Empty dict removes all.
77
+
78
+ Returns a report so the caller can *verify* the hooks landed inside the
79
+ worker rather than infer it from output changes -- a silently failed RPC
80
+ and a patch with no effect look identical from outside.
81
+ """
82
+ import torch
83
+
84
+ model = self._bp_model()
85
+ layers = self._bp_layers(model)
86
+
87
+ for handle in getattr(model, _HOOK_ATTR, []):
88
+ handle.remove()
89
+ setattr(model, _HOOK_ATTR, [])
90
+
91
+ if not deltas:
92
+ return {**self.bp_probe(), "num_hooks": 0}
93
+
94
+ parameter = next(model.parameters())
95
+ device, dtype = parameter.device, parameter.dtype
96
+
97
+ handles = []
98
+ # msgpack may deliver dict keys as strings; normalise before indexing.
99
+ for raw_layer, values in sorted(((int(k), v) for k, v in deltas.items())):
100
+ if raw_layer >= len(layers):
101
+ raise RuntimeError(f"layer {raw_layer} out of range ({len(layers)} blocks)")
102
+ vector = torch.tensor(values, dtype=dtype, device=device)
103
+ handles.append(layers[raw_layer].register_forward_hook(_make_hook(vector)))
104
+
105
+ setattr(model, _HOOK_ATTR, handles)
106
+ return {
107
+ **self.bp_probe(),
108
+ "num_hooks": len(handles),
109
+ "hooked_layers": sorted(int(k) for k in deltas),
110
+ "device": str(device),
111
+ "dtype": str(dtype),
112
+ }
113
+
114
+
115
+ def _make_hook(vector: Any) -> Any:
116
+ """Forward hook adding ``vector`` to a decoder block's hidden states."""
117
+ import torch
118
+
119
+ def hook(module: Any, args: Any, output: Any) -> Any:
120
+ if isinstance(output, tuple):
121
+ hidden = output[0]
122
+ if not isinstance(hidden, torch.Tensor):
123
+ return output
124
+ return (hidden + vector, *output[1:])
125
+ if isinstance(output, torch.Tensor):
126
+ return output + vector
127
+ return output
128
+
129
+ return hook