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,236 @@
|
|
|
1
|
+
"""Minimal, dependency-free safetensors reader and writer.
|
|
2
|
+
|
|
3
|
+
Why this exists
|
|
4
|
+
---------------
|
|
5
|
+
A BrainPatch runtime artifact is a handful of vectors. Reading them should not
|
|
6
|
+
force a user to install numpy, let alone torch: ``pip install brainpatch`` has
|
|
7
|
+
to stay light enough that inspecting, validating and installing a patch works
|
|
8
|
+
on any Python 3.10+ with nothing else present.
|
|
9
|
+
|
|
10
|
+
The safetensors container is simple enough to implement directly:
|
|
11
|
+
|
|
12
|
+
=============== =========================================================
|
|
13
|
+
8 bytes little-endian ``uint64`` header length ``N``
|
|
14
|
+
``N`` bytes UTF-8 JSON header
|
|
15
|
+
remainder raw little-endian tensor data
|
|
16
|
+
=============== =========================================================
|
|
17
|
+
|
|
18
|
+
Each header entry is ``{"dtype": ..., "shape": [...], "data_offsets": [a, b]}``
|
|
19
|
+
with offsets relative to the end of the header. ``__metadata__`` is an optional
|
|
20
|
+
reserved key holding a flat ``str -> str`` map.
|
|
21
|
+
|
|
22
|
+
This module deliberately supports **only** the float dtypes a patch vector can
|
|
23
|
+
use. It is not a general safetensors implementation, and it never executes
|
|
24
|
+
anything from the file -- the whole point of the format choice is that a patch
|
|
25
|
+
is inert data.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import struct
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Iterable, Sequence
|
|
34
|
+
|
|
35
|
+
#: dtype name -> (bytes per element, struct format or None for bf16)
|
|
36
|
+
_DTYPES: dict[str, tuple[int, str | None]] = {
|
|
37
|
+
"F64": (8, "<d"),
|
|
38
|
+
"F32": (4, "<f"),
|
|
39
|
+
"F16": (2, "<e"),
|
|
40
|
+
"BF16": (2, None), # handled specially: upper 16 bits of an f32
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#: Refuse absurd headers before allocating anything.
|
|
44
|
+
MAX_HEADER_BYTES = 16 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SafetensorsError(ValueError):
|
|
48
|
+
"""The byte stream is not a well-formed safetensors container."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Tensor:
|
|
53
|
+
"""A dense float tensor as plain Python floats.
|
|
54
|
+
|
|
55
|
+
Attributes
|
|
56
|
+
----------
|
|
57
|
+
dtype:
|
|
58
|
+
The dtype it was *stored* as. Values are always decoded to Python
|
|
59
|
+
floats; this records the on-disk precision so a round trip can preserve
|
|
60
|
+
it and so a caller can report the real artifact size.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
dtype: str
|
|
64
|
+
shape: tuple[int, ...]
|
|
65
|
+
data: list[float]
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
expected = 1
|
|
69
|
+
for dim in self.shape:
|
|
70
|
+
expected *= dim
|
|
71
|
+
if len(self.data) != expected:
|
|
72
|
+
raise SafetensorsError(
|
|
73
|
+
f"shape {self.shape} implies {expected} elements but got {len(self.data)}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def numel(self) -> int:
|
|
78
|
+
return len(self.data)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def nbytes(self) -> int:
|
|
82
|
+
return self.numel * _DTYPES[self.dtype][0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode_bf16(raw: bytes, count: int) -> list[float]:
|
|
86
|
+
"""bfloat16 is the top 16 bits of an IEEE-754 float32."""
|
|
87
|
+
out: list[float] = []
|
|
88
|
+
for i in range(count):
|
|
89
|
+
bits = raw[2 * i] | (raw[2 * i + 1] << 8)
|
|
90
|
+
out.append(struct.unpack("<f", struct.pack("<I", bits << 16))[0])
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _encode_bf16(values: Sequence[float]) -> bytes:
|
|
95
|
+
"""Round-to-nearest-even truncation of float32 to its top 16 bits."""
|
|
96
|
+
out = bytearray()
|
|
97
|
+
for value in values:
|
|
98
|
+
bits = struct.unpack("<I", struct.pack("<f", float(value)))[0]
|
|
99
|
+
# round-to-nearest-even on the discarded low 16 bits
|
|
100
|
+
rounding = 0x7FFF + ((bits >> 16) & 1)
|
|
101
|
+
bits = (bits + rounding) >> 16
|
|
102
|
+
out += struct.pack("<H", bits & 0xFFFF)
|
|
103
|
+
return bytes(out)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load(blob: bytes) -> tuple[dict[str, Tensor], dict[str, str]]:
|
|
107
|
+
"""Parse a safetensors byte string.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
(tensors, metadata)
|
|
112
|
+
|
|
113
|
+
Raises
|
|
114
|
+
------
|
|
115
|
+
SafetensorsError
|
|
116
|
+
On any structural problem. Offsets are validated against the actual
|
|
117
|
+
payload length, so a truncated or overlapping file is rejected rather
|
|
118
|
+
than silently yielding garbage.
|
|
119
|
+
"""
|
|
120
|
+
if len(blob) < 8:
|
|
121
|
+
raise SafetensorsError("file is too short to contain a header length")
|
|
122
|
+
|
|
123
|
+
(header_len,) = struct.unpack("<Q", blob[:8])
|
|
124
|
+
if header_len > MAX_HEADER_BYTES:
|
|
125
|
+
raise SafetensorsError(f"header claims {header_len} bytes, refusing to read")
|
|
126
|
+
if 8 + header_len > len(blob):
|
|
127
|
+
raise SafetensorsError("header length exceeds file size")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
header = json.loads(blob[8 : 8 + header_len].decode("utf-8"))
|
|
131
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
132
|
+
raise SafetensorsError(f"header is not valid UTF-8 JSON: {exc}") from exc
|
|
133
|
+
if not isinstance(header, dict):
|
|
134
|
+
raise SafetensorsError("header must be a JSON object")
|
|
135
|
+
|
|
136
|
+
payload = blob[8 + header_len :]
|
|
137
|
+
metadata_raw = header.pop("__metadata__", {})
|
|
138
|
+
if not isinstance(metadata_raw, dict):
|
|
139
|
+
raise SafetensorsError("__metadata__ must be a JSON object")
|
|
140
|
+
metadata = {str(k): str(v) for k, v in metadata_raw.items()}
|
|
141
|
+
|
|
142
|
+
tensors: dict[str, Tensor] = {}
|
|
143
|
+
for name, spec in header.items():
|
|
144
|
+
if not isinstance(spec, dict):
|
|
145
|
+
raise SafetensorsError(f"entry {name!r} is not an object")
|
|
146
|
+
dtype = spec.get("dtype")
|
|
147
|
+
if dtype not in _DTYPES:
|
|
148
|
+
raise SafetensorsError(
|
|
149
|
+
f"tensor {name!r} has unsupported dtype {dtype!r}; "
|
|
150
|
+
f"supported: {sorted(_DTYPES)}"
|
|
151
|
+
)
|
|
152
|
+
shape = spec.get("shape")
|
|
153
|
+
if not isinstance(shape, list) or not all(
|
|
154
|
+
isinstance(d, int) and d >= 0 for d in shape
|
|
155
|
+
):
|
|
156
|
+
raise SafetensorsError(f"tensor {name!r} has an invalid shape {shape!r}")
|
|
157
|
+
offsets = spec.get("data_offsets")
|
|
158
|
+
if (
|
|
159
|
+
not isinstance(offsets, list)
|
|
160
|
+
or len(offsets) != 2
|
|
161
|
+
or not all(isinstance(o, int) for o in offsets)
|
|
162
|
+
):
|
|
163
|
+
raise SafetensorsError(f"tensor {name!r} has invalid data_offsets")
|
|
164
|
+
|
|
165
|
+
start, end = offsets
|
|
166
|
+
if not 0 <= start <= end <= len(payload):
|
|
167
|
+
raise SafetensorsError(
|
|
168
|
+
f"tensor {name!r} offsets [{start}, {end}] fall outside the "
|
|
169
|
+
f"{len(payload)}-byte payload"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
item_size, fmt = _DTYPES[dtype]
|
|
173
|
+
raw = payload[start:end]
|
|
174
|
+
if len(raw) % item_size:
|
|
175
|
+
raise SafetensorsError(f"tensor {name!r} byte length is not a multiple of {item_size}")
|
|
176
|
+
count = len(raw) // item_size
|
|
177
|
+
|
|
178
|
+
expected = 1
|
|
179
|
+
for dim in shape:
|
|
180
|
+
expected *= dim
|
|
181
|
+
if count != expected:
|
|
182
|
+
raise SafetensorsError(
|
|
183
|
+
f"tensor {name!r} shape {shape} implies {expected} elements "
|
|
184
|
+
f"but the byte range holds {count}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if fmt is None:
|
|
188
|
+
values = _decode_bf16(raw, count)
|
|
189
|
+
else:
|
|
190
|
+
values = [v[0] for v in struct.iter_unpack(fmt, raw)]
|
|
191
|
+
|
|
192
|
+
tensors[name] = Tensor(dtype=dtype, shape=tuple(shape), data=values)
|
|
193
|
+
|
|
194
|
+
return tensors, metadata
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def dump(tensors: dict[str, Tensor], metadata: dict[str, str] | None = None) -> bytes:
|
|
198
|
+
"""Serialize tensors into a safetensors byte string.
|
|
199
|
+
|
|
200
|
+
Tensor names are written in sorted order so the output is byte-deterministic
|
|
201
|
+
for a given input -- which is what lets a patch carry a stable checksum.
|
|
202
|
+
"""
|
|
203
|
+
header: dict[str, object] = {}
|
|
204
|
+
if metadata:
|
|
205
|
+
header["__metadata__"] = {str(k): str(v) for k, v in metadata.items()}
|
|
206
|
+
|
|
207
|
+
body = bytearray()
|
|
208
|
+
for name in sorted(tensors):
|
|
209
|
+
tensor = tensors[name]
|
|
210
|
+
item_size, fmt = _DTYPES[tensor.dtype]
|
|
211
|
+
if fmt is None:
|
|
212
|
+
raw = _encode_bf16(tensor.data)
|
|
213
|
+
else:
|
|
214
|
+
raw = b"".join(struct.pack(fmt, float(v)) for v in tensor.data)
|
|
215
|
+
start = len(body)
|
|
216
|
+
body += raw
|
|
217
|
+
header[name] = {
|
|
218
|
+
"dtype": tensor.dtype,
|
|
219
|
+
"shape": list(tensor.shape),
|
|
220
|
+
"data_offsets": [start, len(body)],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
header_bytes = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
224
|
+
return struct.pack("<Q", len(header_bytes)) + header_bytes + bytes(body)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def vector(values: Iterable[float], dtype: str = "F32") -> Tensor:
|
|
228
|
+
"""Build a 1-D tensor from an iterable of floats."""
|
|
229
|
+
if dtype not in _DTYPES:
|
|
230
|
+
raise SafetensorsError(f"unsupported dtype {dtype!r}")
|
|
231
|
+
data = [float(v) for v in values]
|
|
232
|
+
return Tensor(dtype=dtype, shape=(len(data),), data=data)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def supported_dtypes() -> tuple[str, ...]:
|
|
236
|
+
return tuple(sorted(_DTYPES))
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Patch-to-model compatibility checking.
|
|
2
|
+
|
|
3
|
+
A patch vector is a direction in one model's residual basis at one layer. The
|
|
4
|
+
same architecture with different weights has a different basis, so "it is also a
|
|
5
|
+
Qwen2ForCausalLM" is not evidence that a direction transfers. Applying a patch
|
|
6
|
+
to the wrong model does not degrade gracefully -- it adds an arbitrary vector and
|
|
7
|
+
produces confident nonsense, which is worse than an error.
|
|
8
|
+
|
|
9
|
+
Hence three explicit modes, defaulting to the strictest:
|
|
10
|
+
|
|
11
|
+
``strict`` (default)
|
|
12
|
+
Model id must match. Revision must match when both are known. Hidden size
|
|
13
|
+
and layer count must match.
|
|
14
|
+
|
|
15
|
+
``architecture``
|
|
16
|
+
Model id may differ but the architecture string, hidden size and layer count
|
|
17
|
+
must match. For fine-tunes and merges of the same base. Warns.
|
|
18
|
+
|
|
19
|
+
``unsafe``
|
|
20
|
+
Only the geometry is checked -- hidden size must match and the layer must
|
|
21
|
+
exist, because anything else cannot be executed at all. Warns loudly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
from brainpatch.patch.format import Manifest
|
|
30
|
+
|
|
31
|
+
CompatibilityMode = Literal["strict", "architecture", "unsafe"]
|
|
32
|
+
COMPATIBILITY_MODES: tuple[str, ...] = ("strict", "architecture", "unsafe")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PatchCompatibilityError(ValueError):
|
|
36
|
+
"""The patch does not match the model it is being applied to."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ModelDescriptor:
|
|
41
|
+
"""What the backend knows about the loaded model."""
|
|
42
|
+
|
|
43
|
+
model_id: str
|
|
44
|
+
hidden_size: int
|
|
45
|
+
num_layers: int
|
|
46
|
+
architecture: str = ""
|
|
47
|
+
revision: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class CompatibilityReport:
|
|
52
|
+
"""Outcome of a compatibility check."""
|
|
53
|
+
|
|
54
|
+
ok: bool
|
|
55
|
+
mode: str
|
|
56
|
+
errors: list[str] = field(default_factory=list)
|
|
57
|
+
warnings: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
def raise_if_failed(self) -> None:
|
|
60
|
+
if not self.ok:
|
|
61
|
+
raise PatchCompatibilityError(
|
|
62
|
+
"patch is not compatible with this model:\n - "
|
|
63
|
+
+ "\n - ".join(self.errors)
|
|
64
|
+
+ f"\n\nChecked in '{self.mode}' mode. If you understand the risk, "
|
|
65
|
+
"re-run with compatibility_mode='architecture' or 'unsafe'."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"ok": self.ok,
|
|
71
|
+
"mode": self.mode,
|
|
72
|
+
"errors": list(self.errors),
|
|
73
|
+
"warnings": list(self.warnings),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_compatibility(
|
|
78
|
+
manifest: Manifest,
|
|
79
|
+
model: ModelDescriptor,
|
|
80
|
+
*,
|
|
81
|
+
mode: CompatibilityMode = "strict",
|
|
82
|
+
) -> CompatibilityReport:
|
|
83
|
+
"""Check a patch against a loaded model. Never raises; inspect ``.ok``."""
|
|
84
|
+
if mode not in COMPATIBILITY_MODES:
|
|
85
|
+
raise ValueError(f"unknown compatibility mode {mode!r}; expected {COMPATIBILITY_MODES}")
|
|
86
|
+
|
|
87
|
+
errors: list[str] = []
|
|
88
|
+
warnings: list[str] = []
|
|
89
|
+
spec = manifest.base_model
|
|
90
|
+
|
|
91
|
+
# -- geometry: required in every mode, because it is what makes the
|
|
92
|
+
# arithmetic executable at all.
|
|
93
|
+
if spec.hidden_size != model.hidden_size:
|
|
94
|
+
errors.append(
|
|
95
|
+
f"hidden size mismatch: patch was built for {spec.hidden_size}, "
|
|
96
|
+
f"model has {model.hidden_size}"
|
|
97
|
+
)
|
|
98
|
+
for layer in manifest.layers:
|
|
99
|
+
if layer >= model.num_layers:
|
|
100
|
+
errors.append(
|
|
101
|
+
f"patch targets layer {layer} but the model has {model.num_layers} layers"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if mode == "unsafe":
|
|
105
|
+
if spec.model_id != model.model_id:
|
|
106
|
+
warnings.append(
|
|
107
|
+
f"UNSAFE MODE: applying a patch built for {spec.model_id!r} to "
|
|
108
|
+
f"{model.model_id!r}. Feature directions are not transferable between "
|
|
109
|
+
"models; output is not meaningful."
|
|
110
|
+
)
|
|
111
|
+
return CompatibilityReport(ok=not errors, mode=mode, errors=errors, warnings=warnings)
|
|
112
|
+
|
|
113
|
+
# -- architecture-level checks
|
|
114
|
+
if spec.num_layers and spec.num_layers != model.num_layers:
|
|
115
|
+
errors.append(
|
|
116
|
+
f"layer count mismatch: patch declares {spec.num_layers}, "
|
|
117
|
+
f"model has {model.num_layers}"
|
|
118
|
+
)
|
|
119
|
+
if spec.architecture and model.architecture and spec.architecture != model.architecture:
|
|
120
|
+
errors.append(
|
|
121
|
+
f"architecture mismatch: patch is for {spec.architecture!r}, "
|
|
122
|
+
f"model is {model.architecture!r}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if mode == "architecture":
|
|
126
|
+
if spec.model_id != model.model_id:
|
|
127
|
+
warnings.append(
|
|
128
|
+
f"model id differs (patch: {spec.model_id!r}, loaded: {model.model_id!r}). "
|
|
129
|
+
"Allowed in 'architecture' mode, but the direction was fitted on "
|
|
130
|
+
"different weights and may not transfer."
|
|
131
|
+
)
|
|
132
|
+
return CompatibilityReport(ok=not errors, mode=mode, errors=errors, warnings=warnings)
|
|
133
|
+
|
|
134
|
+
# -- strict
|
|
135
|
+
if spec.model_id != model.model_id:
|
|
136
|
+
errors.append(
|
|
137
|
+
f"model mismatch: patch targets {spec.model_id!r} but {model.model_id!r} "
|
|
138
|
+
"is loaded"
|
|
139
|
+
)
|
|
140
|
+
if spec.revision and model.revision and spec.revision != model.revision:
|
|
141
|
+
errors.append(
|
|
142
|
+
f"revision mismatch: patch was derived from {spec.revision[:12]}… but "
|
|
143
|
+
f"{model.revision[:12]}… is loaded"
|
|
144
|
+
)
|
|
145
|
+
elif spec.revision and not model.revision:
|
|
146
|
+
warnings.append(
|
|
147
|
+
f"patch pins revision {spec.revision[:12]}… but the loaded model's "
|
|
148
|
+
"revision is unknown, so it could not be checked"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return CompatibilityReport(ok=not errors, mode=mode, errors=errors, warnings=warnings)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_strength(manifest: Manifest, strength: float) -> float:
|
|
155
|
+
"""Clamp a live strength to the patch's declared envelope, warning if clipped."""
|
|
156
|
+
clamped = manifest.clamp_strength(strength)
|
|
157
|
+
return clamped
|
brainpatch/paths.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Canonical layout of the BrainPatch Modal Volume.
|
|
2
|
+
|
|
3
|
+
The Volume ``brainpatch-data`` is mounted at ``/vol`` inside Modal containers.
|
|
4
|
+
All expensive or large artifacts live there; none of it is ever copied to a
|
|
5
|
+
local machine.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
/vol/
|
|
10
|
+
|-- hf-cache/ Hugging Face model + dataset cache
|
|
11
|
+
|-- datasets/ preprocessed text corpora
|
|
12
|
+
|-- activations/<experiment>/ immutable activation shards + manifest
|
|
13
|
+
|-- sae/<experiment>/ SAE checkpoints + training metrics
|
|
14
|
+
|-- feature-db/<experiment>/ per-feature statistics and contexts
|
|
15
|
+
|-- patches/ BrainPatch json files
|
|
16
|
+
|-- experiments/<experiment>/ causal-validation artifacts
|
|
17
|
+
`-- reports/ generated markdown/html reports
|
|
18
|
+
|
|
19
|
+
This module is deliberately dependency-free so it can be used both locally
|
|
20
|
+
(to build CLI messages) and remotely (to actually touch the filesystem).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import PurePosixPath
|
|
27
|
+
|
|
28
|
+
#: Mount point of the ``brainpatch-data`` Volume inside Modal containers.
|
|
29
|
+
DEFAULT_VOLUME_ROOT = "/vol"
|
|
30
|
+
|
|
31
|
+
#: Name of the Modal Volume holding all persistent artifacts.
|
|
32
|
+
VOLUME_NAME = "brainpatch-data"
|
|
33
|
+
|
|
34
|
+
#: Name of the Modal Secret exposing ``HF_TOKEN``.
|
|
35
|
+
HF_SECRET_NAME = "huggingface-secret"
|
|
36
|
+
|
|
37
|
+
#: Modal environment this project is developed in.
|
|
38
|
+
MODAL_ENVIRONMENT = "brainpatch-dev"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class VolumePaths:
|
|
43
|
+
"""Resolver for every path on the BrainPatch Volume.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
root:
|
|
48
|
+
Volume mount point. Defaults to ``/vol``.
|
|
49
|
+
|
|
50
|
+
Notes
|
|
51
|
+
-----
|
|
52
|
+
Paths are :class:`~pathlib.PurePosixPath` because the Volume is always
|
|
53
|
+
mounted in a Linux container, even when this code is *constructed* on
|
|
54
|
+
Windows. Converting to :class:`pathlib.Path` only happens on the remote
|
|
55
|
+
side, where the platform is known to be POSIX.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
root: str = DEFAULT_VOLUME_ROOT
|
|
59
|
+
|
|
60
|
+
# -- top-level directories ------------------------------------------------
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def base(self) -> PurePosixPath:
|
|
64
|
+
return PurePosixPath(self.root)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def hf_cache(self) -> PurePosixPath:
|
|
68
|
+
return self.base / "hf-cache"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def datasets(self) -> PurePosixPath:
|
|
72
|
+
return self.base / "datasets"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def activations_root(self) -> PurePosixPath:
|
|
76
|
+
return self.base / "activations"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def sae_root(self) -> PurePosixPath:
|
|
80
|
+
return self.base / "sae"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def feature_db_root(self) -> PurePosixPath:
|
|
84
|
+
return self.base / "feature-db"
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def patches(self) -> PurePosixPath:
|
|
88
|
+
return self.base / "patches"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def experiments_root(self) -> PurePosixPath:
|
|
92
|
+
return self.base / "experiments"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def reports(self) -> PurePosixPath:
|
|
96
|
+
return self.base / "reports"
|
|
97
|
+
|
|
98
|
+
def all_top_level(self) -> tuple[PurePosixPath, ...]:
|
|
99
|
+
"""Every directory that should exist on a freshly initialised Volume."""
|
|
100
|
+
return (
|
|
101
|
+
self.hf_cache,
|
|
102
|
+
self.datasets,
|
|
103
|
+
self.activations_root,
|
|
104
|
+
self.sae_root,
|
|
105
|
+
self.feature_db_root,
|
|
106
|
+
self.patches,
|
|
107
|
+
self.experiments_root,
|
|
108
|
+
self.reports,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# -- per-experiment directories -------------------------------------------
|
|
112
|
+
|
|
113
|
+
def activations(self, experiment: str) -> PurePosixPath:
|
|
114
|
+
"""Directory holding activation shards for ``experiment``."""
|
|
115
|
+
return self.activations_root / experiment
|
|
116
|
+
|
|
117
|
+
def activation_manifest(self, experiment: str) -> PurePosixPath:
|
|
118
|
+
return self.activations(experiment) / "manifest.json"
|
|
119
|
+
|
|
120
|
+
def activation_examples(self, experiment: str) -> PurePosixPath:
|
|
121
|
+
"""JSONL of source examples; token metadata references these by index."""
|
|
122
|
+
return self.activations(experiment) / "examples.jsonl"
|
|
123
|
+
|
|
124
|
+
def activation_shard(self, experiment: str, index: int) -> PurePosixPath:
|
|
125
|
+
"""Immutable shard path. Shard names are stable and never rewritten."""
|
|
126
|
+
return self.activations(experiment) / shard_filename(index)
|
|
127
|
+
|
|
128
|
+
def sae(self, experiment: str) -> PurePosixPath:
|
|
129
|
+
return self.sae_root / experiment
|
|
130
|
+
|
|
131
|
+
def sae_checkpoint(self, experiment: str, name: str = "sae_latest.pt") -> PurePosixPath:
|
|
132
|
+
return self.sae(experiment) / name
|
|
133
|
+
|
|
134
|
+
def sae_config(self, experiment: str) -> PurePosixPath:
|
|
135
|
+
return self.sae(experiment) / "config.json"
|
|
136
|
+
|
|
137
|
+
def sae_metrics(self, experiment: str) -> PurePosixPath:
|
|
138
|
+
return self.sae(experiment) / "metrics.jsonl"
|
|
139
|
+
|
|
140
|
+
def feature_db(self, experiment: str) -> PurePosixPath:
|
|
141
|
+
return self.feature_db_root / experiment
|
|
142
|
+
|
|
143
|
+
def features_jsonl(self, experiment: str) -> PurePosixPath:
|
|
144
|
+
return self.feature_db(experiment) / "features.jsonl"
|
|
145
|
+
|
|
146
|
+
def feature_summary(self, experiment: str) -> PurePosixPath:
|
|
147
|
+
return self.feature_db(experiment) / "summary.json"
|
|
148
|
+
|
|
149
|
+
def experiment(self, experiment: str) -> PurePosixPath:
|
|
150
|
+
return self.experiments_root / experiment
|
|
151
|
+
|
|
152
|
+
def experiment_file(self, experiment: str, filename: str) -> PurePosixPath:
|
|
153
|
+
return self.experiment(experiment) / filename
|
|
154
|
+
|
|
155
|
+
def patch(self, name: str) -> PurePosixPath:
|
|
156
|
+
return self.patches / f"{name}.json"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def shard_filename(index: int) -> str:
|
|
160
|
+
"""Return the immutable filename for activation shard ``index``.
|
|
161
|
+
|
|
162
|
+
Shard names are zero-padded to six digits so lexical order matches numeric
|
|
163
|
+
order, which lets a streaming reader glob-and-sort without parsing.
|
|
164
|
+
|
|
165
|
+
>>> shard_filename(0)
|
|
166
|
+
'shard_000000.safetensors'
|
|
167
|
+
>>> shard_filename(42)
|
|
168
|
+
'shard_000042.safetensors'
|
|
169
|
+
"""
|
|
170
|
+
if index < 0:
|
|
171
|
+
raise ValueError(f"shard index must be non-negative, got {index}")
|
|
172
|
+
return f"shard_{index:06d}.safetensors"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_shard_index(filename: str) -> int:
|
|
176
|
+
"""Inverse of :func:`shard_filename`.
|
|
177
|
+
|
|
178
|
+
>>> parse_shard_index("shard_000042.safetensors")
|
|
179
|
+
42
|
|
180
|
+
"""
|
|
181
|
+
stem = filename.rsplit("/", 1)[-1]
|
|
182
|
+
if not stem.startswith("shard_") or not stem.endswith(".safetensors"):
|
|
183
|
+
raise ValueError(f"not a shard filename: {filename!r}")
|
|
184
|
+
return int(stem[len("shard_") : -len(".safetensors")])
|
brainpatch/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Research toolkit: how BrainPatches are *created*.
|
|
2
|
+
|
|
3
|
+
Everything here is for patch **authors** -- activation extraction, SAE training,
|
|
4
|
+
feature discovery, causal validation, patch search. It needs torch and is
|
|
5
|
+
installed by the ``research`` extra::
|
|
6
|
+
|
|
7
|
+
pip install "brainpatch[research]"
|
|
8
|
+
|
|
9
|
+
**The runtime never imports this.** A user applying a patch needs none of it: no
|
|
10
|
+
SAE, no activation corpus, no training code. That separation is the product.
|
|
11
|
+
|
|
12
|
+
Where the compute runs is an author's choice. This repository's own experiments
|
|
13
|
+
used Modal (see ``modal_app/``) because the development machine deliberately
|
|
14
|
+
carries no ML stack, but nothing here requires it -- a local GPU, a cluster, or
|
|
15
|
+
a notebook works equally well.
|
|
16
|
+
"""
|