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,213 @@
1
+ """MLX-LM backend for Apple Silicon.
2
+
3
+ Status: **experimental, unverified on hardware.**
4
+
5
+ This adapter is implemented against the MLX-LM API but has **not** been executed
6
+ on an Apple Silicon machine, because none was available during development and
7
+ Modal offers no Apple runners. It is shipped as ``experimental`` rather than
8
+ advertised as supported, and :meth:`MLXBackend.capabilities` says so in the
9
+ notes that ``brainpatch doctor`` prints.
10
+
11
+ If you run it on real hardware, the integration checklist is in
12
+ ``integration_tests/README.md``; a report either way is genuinely useful.
13
+
14
+ Implementation note
15
+ -------------------
16
+ MLX has no ``register_forward_hook``. Instead this wraps the target decoder
17
+ block's ``__call__`` with a closure that adds the delta to the block's output --
18
+ the same intervention, expressed the way MLX modules allow. The original method
19
+ is restored on removal, so the model object is left as it was found.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import platform
25
+ from typing import Any
26
+
27
+ from brainpatch.patch.validation import ModelDescriptor
28
+ from brainpatch.runtime.base import BrainPatchBackend, GenerationConfig
29
+ from brainpatch.runtime.capabilities import Capabilities
30
+
31
+ _LAYER_PATHS = ("model.layers", "layers", "transformer.h")
32
+
33
+
34
+ def _is_apple_silicon() -> bool:
35
+ return platform.system() == "Darwin" and platform.machine() == "arm64"
36
+
37
+
38
+ class MLXBackend(BrainPatchBackend):
39
+ """Apply BrainPatches to an MLX-LM model on Apple Silicon."""
40
+
41
+ name = "mlx"
42
+
43
+ def __init__(self) -> None:
44
+ super().__init__()
45
+ self.model: Any = None
46
+ self.tokenizer: Any = None
47
+ self.model_id: str = ""
48
+ self._originals: dict[int, Any] = {}
49
+ self._vector_cache: dict[tuple[str, str], Any] = {}
50
+
51
+ # -- availability ----------------------------------------------------------
52
+
53
+ @classmethod
54
+ def is_available(cls) -> tuple[bool, str]:
55
+ if not _is_apple_silicon():
56
+ return False, "requires Apple Silicon (darwin/arm64)"
57
+ try:
58
+ import mlx_lm # noqa: F401
59
+ except ModuleNotFoundError:
60
+ return False, "mlx-lm not installed -- pip install 'brainpatch[mlx]'"
61
+ try:
62
+ import mlx.core as mx
63
+
64
+ return True, f"mlx-lm on {mx.default_device()}"
65
+ except Exception as exc: # noqa: BLE001
66
+ return False, f"mlx present but unusable: {exc}"
67
+
68
+ @classmethod
69
+ def capabilities(cls) -> Capabilities:
70
+ return Capabilities(
71
+ name=cls.name,
72
+ static_intervention=True,
73
+ dynamic_schedule=False,
74
+ multiple_patches=True,
75
+ streaming=True,
76
+ cpu=False,
77
+ cuda=False,
78
+ apple_silicon=True,
79
+ server=False,
80
+ concurrent_requests=False,
81
+ per_request_strength=False,
82
+ quantization=(),
83
+ notes={
84
+ "static_intervention": (
85
+ "EXPERIMENTAL: implemented against the MLX-LM API but never "
86
+ "executed on Apple Silicon hardware. Treat as unverified."
87
+ ),
88
+ "dynamic_schedule": "Not implemented; would need per-step control in the decode loop.",
89
+ "server": "Not implemented for this backend.",
90
+ },
91
+ )
92
+
93
+ # -- model -----------------------------------------------------------------
94
+
95
+ def load_model(self, model: str, **kwargs: Any) -> None:
96
+ if not _is_apple_silicon():
97
+ raise RuntimeError(
98
+ "the MLX backend requires Apple Silicon. On other hardware use "
99
+ "--backend transformers or --backend llamacpp."
100
+ )
101
+ from mlx_lm import load
102
+
103
+ self.model, self.tokenizer = load(model, **kwargs)
104
+ self.model_id = model
105
+ self._vector_cache.clear()
106
+
107
+ def _decoder_layers(self) -> Any:
108
+ for path in _LAYER_PATHS:
109
+ node: Any = self.model
110
+ for part in path.split("."):
111
+ node = getattr(node, part, None)
112
+ if node is None:
113
+ break
114
+ if node is not None and hasattr(node, "__len__") and len(node) > 0:
115
+ return node
116
+ raise RuntimeError(f"could not locate decoder blocks on {type(self.model).__name__}")
117
+
118
+ def describe_model(self) -> ModelDescriptor:
119
+ if self.model is None:
120
+ raise RuntimeError("no model loaded; call load_model() first")
121
+ args = getattr(self.model, "args", None)
122
+ hidden = int(getattr(args, "hidden_size", 0)) if args else 0
123
+ return ModelDescriptor(
124
+ model_id=self.model_id,
125
+ hidden_size=hidden,
126
+ num_layers=len(self._decoder_layers()),
127
+ architecture=str(getattr(args, "model_type", "")) if args else "",
128
+ )
129
+
130
+ # -- intervention ----------------------------------------------------------
131
+
132
+ def _array_for(self, patch_name: str, key: str) -> Any:
133
+ cache_key = (patch_name, key)
134
+ cached = self._vector_cache.get(cache_key)
135
+ if cached is None:
136
+ import mlx.core as mx
137
+
138
+ cached = mx.array(self.vector_values(patch_name, key))
139
+ self._vector_cache[cache_key] = cached
140
+ return cached
141
+
142
+ def _on_patches_changed(self) -> None:
143
+ self._vector_cache.clear()
144
+ if self.model is not None:
145
+ self._install_wrappers()
146
+
147
+ def _install_wrappers(self) -> None:
148
+ """Wrap each patched block's ``__call__`` to add the delta."""
149
+ self._remove_wrappers()
150
+ layers = self._decoder_layers()
151
+ for layer_index in sorted(
152
+ {i.layer for p in self.patches.values() for i in p.manifest.interventions}
153
+ ):
154
+ if layer_index >= len(layers):
155
+ continue
156
+ block = layers[layer_index]
157
+ original = block.__call__
158
+ self._originals[layer_index] = original
159
+ block.__call__ = self._make_wrapper(layer_index, original) # type: ignore[method-assign]
160
+
161
+ def _make_wrapper(self, layer_index: int, original: Any) -> Any:
162
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
163
+ output = original(*args, **kwargs)
164
+ edits = self.resolve_edits(0, layer=layer_index)
165
+ if not edits:
166
+ return output
167
+ delta = None
168
+ for edit in edits:
169
+ contribution = self._array_for(edit.patch_name, edit.vector_key) * edit.coefficient
170
+ delta = contribution if delta is None else delta + contribution
171
+ if isinstance(output, tuple):
172
+ return (output[0] + delta, *output[1:])
173
+ return output + delta
174
+
175
+ return wrapped
176
+
177
+ def _remove_wrappers(self) -> None:
178
+ if self.model is None:
179
+ self._originals.clear()
180
+ return
181
+ layers = self._decoder_layers()
182
+ for layer_index, original in self._originals.items():
183
+ if layer_index < len(layers):
184
+ layers[layer_index].__call__ = original # type: ignore[method-assign]
185
+ self._originals.clear()
186
+
187
+ # -- generation ------------------------------------------------------------
188
+
189
+ def generate(
190
+ self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
191
+ ) -> str:
192
+ from mlx_lm import generate as mlx_generate
193
+
194
+ if self.model is None:
195
+ raise RuntimeError("no model loaded; call load_model() first")
196
+ cfg = config or GenerationConfig()
197
+ return mlx_generate(
198
+ self.model,
199
+ self.tokenizer,
200
+ prompt=prompt,
201
+ max_tokens=cfg.max_new_tokens,
202
+ verbose=False,
203
+ **kwargs,
204
+ )
205
+
206
+ def unload(self) -> None:
207
+ self._remove_wrappers()
208
+ self._vector_cache.clear()
209
+ self.model = None
210
+ self.tokenizer = None
211
+
212
+
213
+ BACKEND = MLXBackend
@@ -0,0 +1,473 @@
1
+ """Hugging Face Transformers backend -- the reference implementation.
2
+
3
+ Runs anywhere PyTorch runs: CUDA, CPU, or Apple MPS. No Modal, no Volume, no
4
+ network beyond whatever ``from_pretrained`` needs to fetch the base model once.
5
+
6
+ How the intervention works
7
+ --------------------------
8
+ A forward hook on decoder block *L* adds a vector to its output -- the residual
9
+ stream after that block has written to it, which is where the contribution is
10
+ visible and from which it propagates to every later block.
11
+
12
+ Two properties this file exists to guarantee:
13
+
14
+ **Weights are never modified.** ``requires_grad_(False)`` plus ``eval()``, and
15
+ the hook adds to activations rather than folding anything into parameters.
16
+ Removing a patch restores the original behaviour exactly, because nothing was
17
+ changed to begin with.
18
+
19
+ **Strength 0 is bit-identical to baseline.** When the resolved edit list is
20
+ empty the hook returns the output object untouched -- not ``output + 0.0``. There
21
+ is no arithmetic to round, so a zeroed patch and an uninstalled patch produce
22
+ the same bytes. :meth:`TransformersBackend.assert_zero_is_baseline` checks this
23
+ empirically rather than trusting the argument.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import platform
29
+ from typing import Any, Callable, Iterator
30
+
31
+ from brainpatch.patch.validation import ModelDescriptor
32
+ from brainpatch.runtime.base import BrainPatchBackend, GenerationConfig
33
+ from brainpatch.runtime.capabilities import Capabilities
34
+
35
+ #: Layouts to try when locating the decoder-block list, in order.
36
+ _LAYER_PATHS = ("model.layers", "transformer.h", "gpt_neox.layers", "model.decoder.layers")
37
+
38
+
39
+ class TransformersBackend(BrainPatchBackend):
40
+ """Apply BrainPatches to a PyTorch Transformers causal LM."""
41
+
42
+ name = "transformers"
43
+
44
+ def __init__(self) -> None:
45
+ super().__init__()
46
+ self.model: Any = None
47
+ self.tokenizer: Any = None
48
+ self.model_id: str = ""
49
+ self.revision: str | None = None
50
+ self.device: Any = None
51
+ self._handles: list[Any] = []
52
+ self._vector_cache: dict[tuple[str, str], Any] = {}
53
+ self._trace: list[tuple[int, float]] = []
54
+ self._apply_to_prompt = True
55
+ self._first_layer = -1
56
+ self._pass_counter = 0
57
+ self._current_pass = 0
58
+
59
+ # -- availability ----------------------------------------------------------
60
+
61
+ @classmethod
62
+ def is_available(cls) -> tuple[bool, str]:
63
+ try:
64
+ import torch # noqa: F401
65
+ except ModuleNotFoundError:
66
+ return False, "PyTorch not installed -- pip install 'brainpatch[transformers]'"
67
+ try:
68
+ import transformers
69
+ except ModuleNotFoundError:
70
+ return False, "transformers not installed -- pip install 'brainpatch[transformers]'"
71
+
72
+ import torch
73
+
74
+ bits = [f"transformers {transformers.__version__}", f"torch {torch.__version__}"]
75
+ if torch.cuda.is_available():
76
+ bits.append(f"CUDA: {torch.cuda.get_device_name(0)}")
77
+ elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
78
+ bits.append("Apple MPS")
79
+ else:
80
+ bits.append("CPU only")
81
+ return True, ", ".join(bits)
82
+
83
+ @classmethod
84
+ def capabilities(cls) -> Capabilities:
85
+ cuda = mps = False
86
+ try:
87
+ import torch
88
+
89
+ cuda = torch.cuda.is_available()
90
+ mps = bool(getattr(torch.backends, "mps", None) and torch.backends.mps.is_available())
91
+ except Exception: # noqa: BLE001 - capabilities must work without torch
92
+ pass
93
+ return Capabilities(
94
+ name=cls.name,
95
+ static_intervention=True,
96
+ dynamic_schedule=True,
97
+ multiple_patches=True,
98
+ streaming=True,
99
+ cpu=True,
100
+ cuda=cuda,
101
+ mps=mps,
102
+ apple_silicon=platform.system() == "Darwin" and platform.machine() == "arm64",
103
+ server=True,
104
+ concurrent_requests=False,
105
+ per_request_strength=False,
106
+ quantization=(),
107
+ notes={
108
+ "concurrent_requests": (
109
+ "Patch state is per-process and mutable; serve one request at a "
110
+ "time or use the vLLM backend for concurrency."
111
+ ),
112
+ "quantization": (
113
+ "bitsandbytes-quantized models load, but no quantization has been "
114
+ "verified end to end for patch behaviour."
115
+ ),
116
+ },
117
+ )
118
+
119
+ # -- model loading ---------------------------------------------------------
120
+
121
+ def load_model(
122
+ self,
123
+ model: str,
124
+ *,
125
+ revision: str | None = None,
126
+ device: str = "auto",
127
+ dtype: str = "auto",
128
+ trust_remote_code: bool = False,
129
+ **kwargs: Any,
130
+ ) -> None:
131
+ """Load a frozen causal LM.
132
+
133
+ ``device="auto"`` prefers CUDA, then MPS, then CPU. ``dtype="auto"``
134
+ picks bfloat16 on CUDA, float16 on MPS, float32 on CPU -- CPU bf16 is
135
+ widely unsupported and silently slow.
136
+ """
137
+ import torch
138
+ from transformers import AutoModelForCausalLM, AutoTokenizer
139
+
140
+ resolved_device = _resolve_device(device)
141
+ torch_dtype = _resolve_dtype(dtype, resolved_device)
142
+
143
+ self.tokenizer = AutoTokenizer.from_pretrained(
144
+ model, revision=revision, trust_remote_code=trust_remote_code
145
+ )
146
+ self.model = AutoModelForCausalLM.from_pretrained(
147
+ model,
148
+ revision=revision,
149
+ torch_dtype=torch_dtype,
150
+ trust_remote_code=trust_remote_code,
151
+ low_cpu_mem_usage=True,
152
+ **kwargs,
153
+ )
154
+ self.model.to(resolved_device)
155
+ # Frozen for the process lifetime: every effect comes from activations.
156
+ self.model.eval()
157
+ self.model.requires_grad_(False)
158
+
159
+ self.model_id = model
160
+ self.revision = revision or _cached_revision(self.model)
161
+ self.device = resolved_device
162
+ self._vector_cache.clear()
163
+
164
+ def describe_model(self) -> ModelDescriptor:
165
+ if self.model is None:
166
+ raise RuntimeError("no model loaded; call load_model() first")
167
+ config = self.model.config
168
+ hidden = int(getattr(config, "hidden_size", getattr(config, "n_embd", 0)))
169
+ architectures = getattr(config, "architectures", None) or []
170
+ return ModelDescriptor(
171
+ model_id=self.model_id,
172
+ hidden_size=hidden,
173
+ num_layers=len(self._decoder_layers()),
174
+ architecture=architectures[0] if architectures else type(self.model).__name__,
175
+ revision=self.revision,
176
+ )
177
+
178
+ def _decoder_layers(self) -> Any:
179
+ """Locate the decoder-block ModuleList without hardcoding one layout."""
180
+ import torch.nn as nn
181
+
182
+ for path in _LAYER_PATHS:
183
+ node: Any = self.model
184
+ for part in path.split("."):
185
+ node = getattr(node, part, None)
186
+ if node is None:
187
+ break
188
+ if isinstance(node, nn.ModuleList) and len(node) > 0:
189
+ return node
190
+
191
+ best = None
192
+ for module in self.model.modules():
193
+ if isinstance(module, nn.ModuleList) and len(module) > 1:
194
+ if len({type(m) for m in module}) == 1:
195
+ if best is None or len(module) > len(best):
196
+ best = module
197
+ if best is None:
198
+ raise RuntimeError(
199
+ f"could not locate decoder blocks on {type(self.model).__name__}"
200
+ )
201
+ return best
202
+
203
+ # -- intervention ----------------------------------------------------------
204
+
205
+ def _tensor_for(self, patch_name: str, key: str) -> Any:
206
+ """Cached on-device float32 tensor for a patch vector."""
207
+ cache_key = (patch_name, key)
208
+ cached = self._vector_cache.get(cache_key)
209
+ if cached is None:
210
+ import torch
211
+
212
+ values = self.vector_values(patch_name, key)
213
+ cached = torch.tensor(values, dtype=torch.float32, device=self.device)
214
+ self._vector_cache[cache_key] = cached
215
+ return cached
216
+
217
+ def _on_patches_changed(self) -> None:
218
+ self._vector_cache.clear()
219
+
220
+ def _delta_for(
221
+ self, layer: int, token_index: int, is_prompt_pass: bool | None = None
222
+ ) -> Any | None:
223
+ """Combined delta for one layer, or None when nothing applies."""
224
+ edits = self.resolve_edits(token_index, layer=layer, is_prompt_pass=is_prompt_pass)
225
+ if not edits:
226
+ return None
227
+ import torch
228
+
229
+ delta: Any = None
230
+ for edit in edits:
231
+ vector = self._tensor_for(edit.patch_name, edit.vector_key)
232
+ contribution = vector * edit.coefficient
233
+ delta = contribution if delta is None else delta + contribution
234
+ return delta
235
+
236
+ def _hooked_layers(self) -> list[int]:
237
+ return sorted(
238
+ {i.layer for p in self.patches.values() for i in p.manifest.interventions}
239
+ )
240
+
241
+ def _install_hooks(self) -> None:
242
+ """Attach one forward hook per layer any installed patch touches."""
243
+ self._remove_hooks()
244
+ layers = self._decoder_layers()
245
+ hooked = self._hooked_layers()
246
+ # The lowest hooked layer is reached first in every forward pass, so it
247
+ # is the reliable place to advance the pass counter exactly once.
248
+ self._first_layer = hooked[0] if hooked else -1
249
+ self._pass_counter = 0
250
+ self._current_pass = 0
251
+ for layer_index in hooked:
252
+ handle = layers[layer_index].register_forward_hook(self._make_hook(layer_index))
253
+ self._handles.append(handle)
254
+
255
+ def _make_hook(self, layer_index: int) -> Callable[..., Any]:
256
+ def hook(module: Any, args: Any, output: Any) -> Any:
257
+ if layer_index == self._first_layer:
258
+ self._current_pass = self._pass_counter
259
+ self._pass_counter += 1
260
+
261
+ # Pass 0 processes the whole prompt; pass n>0 emits generated token
262
+ # n-1. So the generated-token index is pass - 1, and the prompt pass
263
+ # is not counted -- which is what a user means by "token 20".
264
+ is_prompt_pass = self._current_pass == 0
265
+ token_index = 0 if is_prompt_pass else self._current_pass - 1
266
+
267
+ if is_prompt_pass and not self._apply_to_prompt:
268
+ return output
269
+
270
+ delta = self._delta_for(layer_index, token_index, is_prompt_pass)
271
+ if delta is None:
272
+ # Untouched: identical to running with no hook at all.
273
+ return output
274
+
275
+ hidden, rebuild = _split_output(output)
276
+ if layer_index == self._first_layer:
277
+ self._trace.append((token_index, float(delta.norm().item())))
278
+ return rebuild(hidden + delta.to(dtype=hidden.dtype, device=hidden.device))
279
+
280
+ return hook
281
+
282
+ def _remove_hooks(self) -> None:
283
+ for handle in self._handles:
284
+ handle.remove()
285
+ self._handles.clear()
286
+
287
+ # -- generation ------------------------------------------------------------
288
+
289
+ def _prepare(self, prompt: str, use_chat_template: bool, system: str | None) -> str:
290
+ if not use_chat_template or getattr(self.tokenizer, "chat_template", None) is None:
291
+ return prompt
292
+ messages = []
293
+ if system:
294
+ messages.append({"role": "system", "content": system})
295
+ messages.append({"role": "user", "content": prompt})
296
+ return self.tokenizer.apply_chat_template(
297
+ messages, tokenize=False, add_generation_prompt=True
298
+ )
299
+
300
+ def generate(
301
+ self,
302
+ prompt: str,
303
+ config: GenerationConfig | None = None,
304
+ *,
305
+ use_chat_template: bool = True,
306
+ system: str | None = None,
307
+ apply_to_prompt: bool = True,
308
+ **kwargs: Any,
309
+ ) -> str:
310
+ import torch
311
+
312
+ if self.model is None:
313
+ raise RuntimeError("no model loaded; call load_model() first")
314
+
315
+ cfg = config or GenerationConfig()
316
+ text = self._prepare(prompt, use_chat_template, system)
317
+ inputs = self.tokenizer(text, return_tensors="pt").to(self.device)
318
+
319
+ gen_kwargs: dict[str, Any] = {
320
+ "max_new_tokens": cfg.max_new_tokens,
321
+ "do_sample": cfg.do_sample,
322
+ "pad_token_id": self.tokenizer.pad_token_id or self.tokenizer.eos_token_id,
323
+ }
324
+ if cfg.do_sample:
325
+ gen_kwargs.update(temperature=cfg.temperature, top_p=cfg.top_p)
326
+ if cfg.top_k > 0:
327
+ gen_kwargs["top_k"] = cfg.top_k
328
+ torch.manual_seed(cfg.seed)
329
+ if cfg.repetition_penalty != 1.0:
330
+ gen_kwargs["repetition_penalty"] = cfg.repetition_penalty
331
+ gen_kwargs.update(kwargs)
332
+
333
+ self._trace = []
334
+ self._apply_to_prompt = apply_to_prompt
335
+ self._install_hooks()
336
+ try:
337
+ with torch.inference_mode():
338
+ output = self.model.generate(**inputs, **gen_kwargs)
339
+ finally:
340
+ self._remove_hooks()
341
+
342
+ generated = output[0, inputs["input_ids"].shape[1] :]
343
+ return self.tokenizer.decode(generated, skip_special_tokens=True)
344
+
345
+ def stream(
346
+ self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
347
+ ) -> Iterator[str]:
348
+ """Token-by-token streaming via ``TextIteratorStreamer``."""
349
+ import threading
350
+
351
+ from transformers import TextIteratorStreamer
352
+
353
+ streamer = TextIteratorStreamer(
354
+ self.tokenizer, skip_prompt=True, skip_special_tokens=True
355
+ )
356
+ thread = threading.Thread(
357
+ target=self.generate, args=(prompt, config), kwargs={**kwargs, "streamer": streamer}
358
+ )
359
+ thread.start()
360
+ try:
361
+ for chunk in streamer:
362
+ yield chunk
363
+ finally:
364
+ thread.join()
365
+
366
+ # -- diagnostics -----------------------------------------------------------
367
+
368
+ @property
369
+ def last_trace(self) -> list[tuple[int, float]]:
370
+ """``(generated_token_index, delta_norm)`` for the last generation."""
371
+ return list(self._trace)
372
+
373
+ def assert_zero_is_baseline(
374
+ self, prompt: str, config: GenerationConfig | None = None
375
+ ) -> dict[str, Any]:
376
+ """Empirically verify that zeroed patches reproduce baseline exactly.
377
+
378
+ If this ever fails, every baseline measured with this backend is
379
+ contaminated, so it is worth checking rather than assuming.
380
+ """
381
+ cfg = config or GenerationConfig(max_new_tokens=48)
382
+ saved = {name: p.strength for name, p in self.patches.items()}
383
+
384
+ installed = dict(self._patches)
385
+ self._patches = {}
386
+ baseline = self.generate(prompt, cfg)
387
+
388
+ self._patches = installed
389
+ for name in self._patches:
390
+ self._patches[name].strength = 0.0
391
+ self._on_patches_changed()
392
+ zeroed = self.generate(prompt, cfg)
393
+ trace = self.last_trace
394
+
395
+ for name, strength in saved.items():
396
+ self._patches[name].strength = strength
397
+ self._on_patches_changed()
398
+
399
+ return {
400
+ "identical": baseline == zeroed,
401
+ "baseline": baseline,
402
+ "zero_strength": zeroed,
403
+ "applied_passes_at_zero": len(trace),
404
+ "num_patches": len(saved),
405
+ }
406
+
407
+ def unload(self) -> None:
408
+ self._remove_hooks()
409
+ self._vector_cache.clear()
410
+ self.model = None
411
+ self.tokenizer = None
412
+
413
+
414
+ def _split_output(output: Any) -> tuple[Any, Callable[[Any], Any]]:
415
+ """Extract hidden states and a rebuilder for a decoder block's output."""
416
+ import torch
417
+
418
+ if isinstance(output, torch.Tensor):
419
+ return output, lambda t: t
420
+ if isinstance(output, tuple):
421
+ if not output or not isinstance(output[0], torch.Tensor):
422
+ raise TypeError(f"unexpected decoder output tuple: {type(output)}")
423
+ rest = output[1:]
424
+ return output[0], lambda t: (t, *rest)
425
+ hidden = getattr(output, "last_hidden_state", None)
426
+ if isinstance(hidden, torch.Tensor):
427
+
428
+ def rebuild(t: Any, _out: Any = output) -> Any:
429
+ _out.last_hidden_state = t
430
+ return _out
431
+
432
+ return hidden, rebuild
433
+ raise TypeError(f"cannot locate hidden states in output of type {type(output)}")
434
+
435
+
436
+ def _resolve_device(device: str) -> Any:
437
+ import torch
438
+
439
+ if device != "auto":
440
+ return torch.device(device)
441
+ if torch.cuda.is_available():
442
+ return torch.device("cuda")
443
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
444
+ return torch.device("mps")
445
+ return torch.device("cpu")
446
+
447
+
448
+ def _resolve_dtype(dtype: str, device: Any) -> Any:
449
+ import torch
450
+
451
+ if dtype != "auto":
452
+ return getattr(torch, dtype)
453
+ kind = device.type
454
+ if kind == "cuda":
455
+ return torch.bfloat16
456
+ if kind == "mps":
457
+ return torch.float16
458
+ return torch.float32
459
+
460
+
461
+ def _cached_revision(model: Any) -> str | None:
462
+ """Best-effort recovery of the commit SHA transformers actually loaded."""
463
+ name_or_path = str(getattr(model.config, "_name_or_path", "") or "")
464
+ parts = name_or_path.replace("\\", "/").split("/")
465
+ if "snapshots" in parts:
466
+ index = parts.index("snapshots")
467
+ if index + 1 < len(parts):
468
+ return parts[index + 1]
469
+ commit = getattr(model.config, "_commit_hash", None)
470
+ return str(commit) if commit else None
471
+
472
+
473
+ BACKEND = TransformersBackend