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