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,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
|