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,312 @@
|
|
|
1
|
+
"""Reading and writing ``.brainpatch`` archives.
|
|
2
|
+
|
|
3
|
+
Security posture
|
|
4
|
+
----------------
|
|
5
|
+
A ``.brainpatch`` file is **untrusted input**. It typically arrives from the
|
|
6
|
+
internet, and the whole reason the format is ZIP-of-JSON-and-safetensors rather
|
|
7
|
+
than a pickle is that applying one must never be able to run code.
|
|
8
|
+
|
|
9
|
+
So this loader:
|
|
10
|
+
|
|
11
|
+
* reads members **by exact name**, never by iterating and trusting what it finds
|
|
12
|
+
* rejects absolute paths, ``..`` traversal, symlinks and directory entries
|
|
13
|
+
* enforces per-member and total size ceilings before decompressing (zip bombs)
|
|
14
|
+
* verifies sha256 for every member against ``checksums.json``
|
|
15
|
+
* parses only JSON and safetensors, both of which are inert
|
|
16
|
+
|
|
17
|
+
Nothing here evaluates, imports, or executes any part of an artifact.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import stat
|
|
26
|
+
import zipfile
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from brainpatch.patch import tensors as ts
|
|
32
|
+
from brainpatch.patch.format import (
|
|
33
|
+
CHECKSUMS_NAME,
|
|
34
|
+
MANIFEST_NAME,
|
|
35
|
+
README_NAME,
|
|
36
|
+
SUFFIX,
|
|
37
|
+
VECTORS_NAME,
|
|
38
|
+
Manifest,
|
|
39
|
+
PatchFormatError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
#: Members the loader will read. Anything else in the archive is ignored, and
|
|
43
|
+
#: an *unexpected* member is reported rather than silently accepted.
|
|
44
|
+
KNOWN_MEMBERS = (MANIFEST_NAME, VECTORS_NAME, CHECKSUMS_NAME, README_NAME)
|
|
45
|
+
|
|
46
|
+
#: A patch is meant to be tiny. These ceilings are generous by two orders of
|
|
47
|
+
#: magnitude and exist purely to bound a hostile archive.
|
|
48
|
+
MAX_MEMBER_BYTES = 256 * 1024 * 1024
|
|
49
|
+
MAX_TOTAL_BYTES = 512 * 1024 * 1024
|
|
50
|
+
MAX_COMPRESSION_RATIO = 200
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PatchLoadError(PatchFormatError):
|
|
54
|
+
"""The archive could not be read safely."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class LoadedPatch:
|
|
59
|
+
"""A parsed, checksum-verified patch artifact."""
|
|
60
|
+
|
|
61
|
+
manifest: Manifest
|
|
62
|
+
vectors: dict[str, ts.Tensor]
|
|
63
|
+
readme: str | None = None
|
|
64
|
+
source: str | None = None
|
|
65
|
+
#: Size of the archive on disk, for honest size reporting.
|
|
66
|
+
archive_bytes: int = 0
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def name(self) -> str:
|
|
70
|
+
return self.manifest.name
|
|
71
|
+
|
|
72
|
+
def vector_for(self, key: str) -> ts.Tensor:
|
|
73
|
+
if key not in self.vectors:
|
|
74
|
+
raise PatchLoadError(
|
|
75
|
+
f"patch {self.name!r} references vector {key!r}, which is not in "
|
|
76
|
+
f"{VECTORS_NAME} (present: {sorted(self.vectors)})"
|
|
77
|
+
)
|
|
78
|
+
return self.vectors[key]
|
|
79
|
+
|
|
80
|
+
def describe(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"name": self.manifest.name,
|
|
83
|
+
"format_version": self.manifest.format_version,
|
|
84
|
+
"description": self.manifest.description,
|
|
85
|
+
"base_model": self.manifest.base_model.to_dict(),
|
|
86
|
+
"num_interventions": len(self.manifest.interventions),
|
|
87
|
+
"layers": self.manifest.layers,
|
|
88
|
+
"num_vectors": len(self.vectors),
|
|
89
|
+
"vector_dtype": next(iter(self.vectors.values())).dtype if self.vectors else None,
|
|
90
|
+
"evidence_level": self.manifest.evidence_level,
|
|
91
|
+
"compatibility": self.manifest.compatibility,
|
|
92
|
+
"archive_bytes": self.archive_bytes,
|
|
93
|
+
"source": self.source,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _safe_member_names(zf: zipfile.ZipFile) -> list[str]:
|
|
98
|
+
"""Validate every entry's name and size before reading anything."""
|
|
99
|
+
total = 0
|
|
100
|
+
names: list[str] = []
|
|
101
|
+
for info in zf.infolist():
|
|
102
|
+
name = info.filename
|
|
103
|
+
|
|
104
|
+
if info.is_dir():
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Path safety first: these are the checks that actually matter, and
|
|
108
|
+
# they give the clearest diagnostics.
|
|
109
|
+
if name.startswith("/") or name.startswith("\\"):
|
|
110
|
+
raise PatchLoadError(f"archive member {name!r} uses an absolute path")
|
|
111
|
+
if ".." in Path(name.replace("\\", "/")).parts:
|
|
112
|
+
raise PatchLoadError(f"archive member {name!r} escapes the archive root")
|
|
113
|
+
if os.path.isabs(name) or (len(name) > 1 and name[1] == ":"):
|
|
114
|
+
raise PatchLoadError(f"archive member {name!r} uses an absolute path")
|
|
115
|
+
|
|
116
|
+
# Unix mode lives in the high 16 bits of external_attr, but the type
|
|
117
|
+
# bits are frequently absent -- CPython's own ``writestr`` stores plain
|
|
118
|
+
# permissions (0o600) with no S_IFREG. So reject only what is positively
|
|
119
|
+
# identified as a symlink or other special file; treat "no type bits" as
|
|
120
|
+
# an ordinary member rather than as suspicious.
|
|
121
|
+
mode = info.external_attr >> 16
|
|
122
|
+
if mode and stat.S_IFMT(mode):
|
|
123
|
+
if stat.S_ISLNK(mode):
|
|
124
|
+
raise PatchLoadError(f"archive member {name!r} is a symlink")
|
|
125
|
+
if not stat.S_ISREG(mode) and not stat.S_ISDIR(mode):
|
|
126
|
+
raise PatchLoadError(f"archive member {name!r} is not a regular file")
|
|
127
|
+
|
|
128
|
+
if info.file_size > MAX_MEMBER_BYTES:
|
|
129
|
+
raise PatchLoadError(
|
|
130
|
+
f"archive member {name!r} is {info.file_size} bytes, over the "
|
|
131
|
+
f"{MAX_MEMBER_BYTES} limit"
|
|
132
|
+
)
|
|
133
|
+
if info.compress_size > 0:
|
|
134
|
+
ratio = info.file_size / info.compress_size
|
|
135
|
+
if ratio > MAX_COMPRESSION_RATIO:
|
|
136
|
+
raise PatchLoadError(
|
|
137
|
+
f"archive member {name!r} has a {ratio:.0f}x compression ratio, "
|
|
138
|
+
"which looks like a zip bomb"
|
|
139
|
+
)
|
|
140
|
+
total += info.file_size
|
|
141
|
+
if total > MAX_TOTAL_BYTES:
|
|
142
|
+
raise PatchLoadError(f"archive expands to over {MAX_TOTAL_BYTES} bytes")
|
|
143
|
+
|
|
144
|
+
names.append(name)
|
|
145
|
+
return names
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_patch(path: str | os.PathLike[str], *, verify_checksums: bool = True) -> LoadedPatch:
|
|
149
|
+
"""Load and verify a ``.brainpatch`` archive.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
verify_checksums:
|
|
154
|
+
Leave this True. It is a parameter only so the writer can round-trip a
|
|
155
|
+
freshly-built archive before its checksums exist.
|
|
156
|
+
|
|
157
|
+
Raises
|
|
158
|
+
------
|
|
159
|
+
PatchLoadError
|
|
160
|
+
On a malformed, unsafe, or checksum-mismatched archive.
|
|
161
|
+
"""
|
|
162
|
+
p = Path(path)
|
|
163
|
+
if not p.is_file():
|
|
164
|
+
raise PatchLoadError(f"patch file not found: {p}")
|
|
165
|
+
|
|
166
|
+
archive_bytes = p.stat().st_size
|
|
167
|
+
try:
|
|
168
|
+
with zipfile.ZipFile(p) as zf:
|
|
169
|
+
present = set(_safe_member_names(zf))
|
|
170
|
+
|
|
171
|
+
unexpected = present - set(KNOWN_MEMBERS)
|
|
172
|
+
if unexpected:
|
|
173
|
+
raise PatchLoadError(
|
|
174
|
+
f"archive contains unexpected members: {sorted(unexpected)}. "
|
|
175
|
+
f"A BrainPatch may only contain {list(KNOWN_MEMBERS)}."
|
|
176
|
+
)
|
|
177
|
+
for required in (MANIFEST_NAME, VECTORS_NAME):
|
|
178
|
+
if required not in present:
|
|
179
|
+
raise PatchLoadError(f"archive is missing required member {required!r}")
|
|
180
|
+
|
|
181
|
+
raw = {name: zf.read(name) for name in present}
|
|
182
|
+
except zipfile.BadZipFile as exc:
|
|
183
|
+
raise PatchLoadError(f"{p} is not a valid archive: {exc}") from exc
|
|
184
|
+
|
|
185
|
+
if verify_checksums:
|
|
186
|
+
if CHECKSUMS_NAME not in raw:
|
|
187
|
+
raise PatchLoadError(f"archive is missing {CHECKSUMS_NAME!r}")
|
|
188
|
+
_verify_checksums(raw)
|
|
189
|
+
|
|
190
|
+
manifest = Manifest.from_json(raw[MANIFEST_NAME].decode("utf-8"))
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
vectors, _ = ts.load(raw[VECTORS_NAME])
|
|
194
|
+
except ts.SafetensorsError as exc:
|
|
195
|
+
raise PatchLoadError(f"{VECTORS_NAME} is malformed: {exc}") from exc
|
|
196
|
+
|
|
197
|
+
# Every referenced vector must exist and be a 1-D tensor of hidden_size.
|
|
198
|
+
hidden = manifest.base_model.hidden_size
|
|
199
|
+
for key in manifest.vector_keys:
|
|
200
|
+
if key not in vectors:
|
|
201
|
+
raise PatchLoadError(
|
|
202
|
+
f"manifest references vector {key!r} which is absent from {VECTORS_NAME}"
|
|
203
|
+
)
|
|
204
|
+
tensor = vectors[key]
|
|
205
|
+
if len(tensor.shape) != 1:
|
|
206
|
+
raise PatchLoadError(
|
|
207
|
+
f"vector {key!r} must be 1-D, got shape {tensor.shape}"
|
|
208
|
+
)
|
|
209
|
+
if tensor.shape[0] != hidden:
|
|
210
|
+
raise PatchLoadError(
|
|
211
|
+
f"vector {key!r} has length {tensor.shape[0]} but the patch declares "
|
|
212
|
+
f"hidden_size {hidden}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
readme = raw.get(README_NAME)
|
|
216
|
+
return LoadedPatch(
|
|
217
|
+
manifest=manifest,
|
|
218
|
+
vectors=vectors,
|
|
219
|
+
readme=readme.decode("utf-8") if readme else None,
|
|
220
|
+
source=str(p),
|
|
221
|
+
archive_bytes=archive_bytes,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _verify_checksums(raw: dict[str, bytes]) -> None:
|
|
226
|
+
try:
|
|
227
|
+
recorded = json.loads(raw[CHECKSUMS_NAME].decode("utf-8"))
|
|
228
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
229
|
+
raise PatchLoadError(f"{CHECKSUMS_NAME} is not valid JSON: {exc}") from exc
|
|
230
|
+
if not isinstance(recorded, dict):
|
|
231
|
+
raise PatchLoadError(f"{CHECKSUMS_NAME} must be a JSON object")
|
|
232
|
+
|
|
233
|
+
for name, blob in raw.items():
|
|
234
|
+
if name == CHECKSUMS_NAME:
|
|
235
|
+
continue
|
|
236
|
+
expected = recorded.get(name)
|
|
237
|
+
if expected is None:
|
|
238
|
+
raise PatchLoadError(f"{CHECKSUMS_NAME} has no entry for member {name!r}")
|
|
239
|
+
actual = hashlib.sha256(blob).hexdigest()
|
|
240
|
+
if actual != expected:
|
|
241
|
+
raise PatchLoadError(
|
|
242
|
+
f"checksum mismatch for {name!r}: manifest records {expected}, "
|
|
243
|
+
f"archive contains {actual}. The patch is corrupt or was modified."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def save_patch(
|
|
248
|
+
manifest: Manifest,
|
|
249
|
+
vectors: dict[str, ts.Tensor],
|
|
250
|
+
path: str | os.PathLike[str],
|
|
251
|
+
*,
|
|
252
|
+
readme: str | None = None,
|
|
253
|
+
overwrite: bool = False,
|
|
254
|
+
) -> Path:
|
|
255
|
+
"""Write a ``.brainpatch`` archive, with checksums, deterministically.
|
|
256
|
+
|
|
257
|
+
ZIP entries are written with a fixed timestamp and in fixed order, so two
|
|
258
|
+
builds from identical inputs produce byte-identical archives -- which is
|
|
259
|
+
what makes a published patch hash verifiable.
|
|
260
|
+
"""
|
|
261
|
+
manifest.validate()
|
|
262
|
+
|
|
263
|
+
p = Path(path)
|
|
264
|
+
if p.suffix != SUFFIX:
|
|
265
|
+
p = p.with_suffix(SUFFIX)
|
|
266
|
+
if p.exists() and not overwrite:
|
|
267
|
+
raise FileExistsError(f"{p} already exists; pass overwrite=True to replace it")
|
|
268
|
+
|
|
269
|
+
missing = [k for k in manifest.vector_keys if k not in vectors]
|
|
270
|
+
if missing:
|
|
271
|
+
raise PatchFormatError(f"manifest references vectors not provided: {missing}")
|
|
272
|
+
|
|
273
|
+
hidden = manifest.base_model.hidden_size
|
|
274
|
+
for key, tensor in vectors.items():
|
|
275
|
+
if len(tensor.shape) != 1 or tensor.shape[0] != hidden:
|
|
276
|
+
raise PatchFormatError(
|
|
277
|
+
f"vector {key!r} must be 1-D of length {hidden}, got shape {tensor.shape}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
members: dict[str, bytes] = {
|
|
281
|
+
MANIFEST_NAME: (manifest.to_json() + "\n").encode("utf-8"),
|
|
282
|
+
VECTORS_NAME: ts.dump(vectors, metadata={"format": "brainpatch-vectors-v1"}),
|
|
283
|
+
}
|
|
284
|
+
if readme:
|
|
285
|
+
members[README_NAME] = readme.encode("utf-8")
|
|
286
|
+
|
|
287
|
+
checksums = {name: hashlib.sha256(blob).hexdigest() for name, blob in sorted(members.items())}
|
|
288
|
+
members[CHECKSUMS_NAME] = (
|
|
289
|
+
json.dumps(checksums, indent=2, sort_keys=True) + "\n"
|
|
290
|
+
).encode("utf-8")
|
|
291
|
+
|
|
292
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
with zipfile.ZipFile(p, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
294
|
+
for name in sorted(members):
|
|
295
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
296
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
297
|
+
info.external_attr = 0o100644 << 16 # regular file, rw-r--r--
|
|
298
|
+
zf.writestr(info, members[name])
|
|
299
|
+
return p
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def patch_size_report(loaded: LoadedPatch) -> dict[str, Any]:
|
|
303
|
+
"""Size breakdown, for honest reporting rather than marketing claims."""
|
|
304
|
+
vector_bytes = sum(t.nbytes for t in loaded.vectors.values())
|
|
305
|
+
return {
|
|
306
|
+
"archive_bytes": loaded.archive_bytes,
|
|
307
|
+
"archive_kb": round(loaded.archive_bytes / 1024, 2),
|
|
308
|
+
"num_vectors": len(loaded.vectors),
|
|
309
|
+
"vector_payload_bytes": vector_bytes,
|
|
310
|
+
"hidden_size": loaded.manifest.base_model.hidden_size,
|
|
311
|
+
"dtype": next(iter(loaded.vectors.values())).dtype if loaded.vectors else None,
|
|
312
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""The local patch registry: ``~/.brainpatch``.
|
|
2
|
+
|
|
3
|
+
Installing a patch means copying a small verified archive into a local store and
|
|
4
|
+
recording where it came from. It does **not** mean downloading a base model --
|
|
5
|
+
patches are tens of KB and models are gigabytes, and conflating the two is how a
|
|
6
|
+
"quick install" becomes a 3 GB surprise.
|
|
7
|
+
|
|
8
|
+
Layout::
|
|
9
|
+
|
|
10
|
+
~/.brainpatch/
|
|
11
|
+
config.json registry-level settings
|
|
12
|
+
patches/
|
|
13
|
+
<name>.brainpatch
|
|
14
|
+
<name>.source.json provenance of the install
|
|
15
|
+
cache/ downloads, safe to delete
|
|
16
|
+
|
|
17
|
+
Everything here is plain files. Uninstalling is deleting them; there is no
|
|
18
|
+
database to corrupt and nothing to migrate.
|
|
19
|
+
|
|
20
|
+
Network access happens only in :func:`install_from_hub`, only via
|
|
21
|
+
``huggingface_hub``, and only for the patch artifact itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shutil
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from brainpatch.patch.format import SUFFIX
|
|
36
|
+
from brainpatch.patch.loader import LoadedPatch, load_patch
|
|
37
|
+
|
|
38
|
+
#: Overridable for tests and for users with an unusual home directory.
|
|
39
|
+
ENV_HOME = "BRAINPATCH_HOME"
|
|
40
|
+
|
|
41
|
+
#: ``owner/repo`` or ``owner/repo:file.brainpatch``
|
|
42
|
+
_HUB_REF_RE = re.compile(
|
|
43
|
+
r"^(?P<repo>[A-Za-z0-9][\w.-]*/[\w.-]+)(?::(?P<file>[\w./-]+))?$"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RegistryError(RuntimeError):
|
|
48
|
+
"""The registry could not satisfy the request."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def registry_home() -> Path:
|
|
52
|
+
"""Root of the local registry, honouring ``BRAINPATCH_HOME``."""
|
|
53
|
+
override = os.environ.get(ENV_HOME)
|
|
54
|
+
if override:
|
|
55
|
+
return Path(override).expanduser()
|
|
56
|
+
return Path.home() / ".brainpatch"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class InstalledPatch:
|
|
61
|
+
"""A patch present in the local registry."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
path: Path
|
|
65
|
+
source: dict[str, Any]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def size_bytes(self) -> int:
|
|
69
|
+
return self.path.stat().st_size
|
|
70
|
+
|
|
71
|
+
def load(self) -> LoadedPatch:
|
|
72
|
+
return load_patch(self.path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PatchRegistry:
|
|
76
|
+
"""File-backed store of installed patches."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, home: str | os.PathLike[str] | None = None) -> None:
|
|
79
|
+
self.home = Path(home).expanduser() if home is not None else registry_home()
|
|
80
|
+
|
|
81
|
+
# -- paths -----------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def patches_dir(self) -> Path:
|
|
85
|
+
return self.home / "patches"
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def cache_dir(self) -> Path:
|
|
89
|
+
return self.home / "cache"
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def config_path(self) -> Path:
|
|
93
|
+
return self.home / "config.json"
|
|
94
|
+
|
|
95
|
+
def ensure_dirs(self) -> None:
|
|
96
|
+
self.patches_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
def path_for(self, name: str) -> Path:
|
|
100
|
+
return self.patches_dir / f"{name}{SUFFIX}"
|
|
101
|
+
|
|
102
|
+
def _source_path(self, name: str) -> Path:
|
|
103
|
+
return self.patches_dir / f"{name}.source.json"
|
|
104
|
+
|
|
105
|
+
# -- queries ---------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def list_patches(self) -> list[InstalledPatch]:
|
|
108
|
+
"""Every installed patch, sorted by name. Missing dir -> empty list."""
|
|
109
|
+
if not self.patches_dir.is_dir():
|
|
110
|
+
return []
|
|
111
|
+
out: list[InstalledPatch] = []
|
|
112
|
+
for path in sorted(self.patches_dir.glob(f"*{SUFFIX}")):
|
|
113
|
+
name = path.name[: -len(SUFFIX)]
|
|
114
|
+
out.append(InstalledPatch(name=name, path=path, source=self._read_source(name)))
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
def is_installed(self, name: str) -> bool:
|
|
118
|
+
return self.path_for(name).is_file()
|
|
119
|
+
|
|
120
|
+
def get(self, name: str) -> InstalledPatch:
|
|
121
|
+
path = self.path_for(name)
|
|
122
|
+
if not path.is_file():
|
|
123
|
+
available = [p.name for p in self.list_patches()]
|
|
124
|
+
raise RegistryError(
|
|
125
|
+
f"patch {name!r} is not installed. "
|
|
126
|
+
+ (f"Installed: {', '.join(available)}" if available else "No patches installed.")
|
|
127
|
+
)
|
|
128
|
+
return InstalledPatch(name=name, path=path, source=self._read_source(name))
|
|
129
|
+
|
|
130
|
+
def resolve(self, ref: str) -> Path:
|
|
131
|
+
"""Resolve a name, a path, or an installed patch to a file on disk.
|
|
132
|
+
|
|
133
|
+
Accepts an installed name first, then a filesystem path -- so a bare
|
|
134
|
+
name never accidentally reads a same-named file from the CWD.
|
|
135
|
+
"""
|
|
136
|
+
if self.is_installed(ref):
|
|
137
|
+
return self.path_for(ref)
|
|
138
|
+
candidate = Path(ref).expanduser()
|
|
139
|
+
if candidate.is_file():
|
|
140
|
+
return candidate
|
|
141
|
+
raise RegistryError(
|
|
142
|
+
f"{ref!r} is neither an installed patch nor an existing file. "
|
|
143
|
+
"Use `brainpatch list` to see what is installed."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _read_source(self, name: str) -> dict[str, Any]:
|
|
147
|
+
path = self._source_path(name)
|
|
148
|
+
if not path.is_file():
|
|
149
|
+
return {}
|
|
150
|
+
try:
|
|
151
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
152
|
+
except (OSError, json.JSONDecodeError):
|
|
153
|
+
return {}
|
|
154
|
+
|
|
155
|
+
# -- mutation --------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def install_file(
|
|
158
|
+
self,
|
|
159
|
+
path: str | os.PathLike[str],
|
|
160
|
+
*,
|
|
161
|
+
name: str | None = None,
|
|
162
|
+
overwrite: bool = False,
|
|
163
|
+
source: dict[str, Any] | None = None,
|
|
164
|
+
) -> InstalledPatch:
|
|
165
|
+
"""Verify then install a local ``.brainpatch`` file.
|
|
166
|
+
|
|
167
|
+
The archive is fully loaded and checksum-verified *before* anything is
|
|
168
|
+
written, so a corrupt download cannot land in the registry.
|
|
169
|
+
"""
|
|
170
|
+
src = Path(path).expanduser()
|
|
171
|
+
loaded = load_patch(src) # raises on anything malformed or unsafe
|
|
172
|
+
|
|
173
|
+
patch_name = name or loaded.manifest.name
|
|
174
|
+
target = self.path_for(patch_name)
|
|
175
|
+
if target.exists() and not overwrite:
|
|
176
|
+
raise RegistryError(
|
|
177
|
+
f"patch {patch_name!r} is already installed. "
|
|
178
|
+
"Pass --force to replace it."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self.ensure_dirs()
|
|
182
|
+
shutil.copyfile(src, target)
|
|
183
|
+
|
|
184
|
+
record = {
|
|
185
|
+
"installed_at": datetime.now(timezone.utc).isoformat(),
|
|
186
|
+
"origin": str(src),
|
|
187
|
+
"kind": "file",
|
|
188
|
+
"format_version": loaded.manifest.format_version,
|
|
189
|
+
"base_model": loaded.manifest.base_model.model_id,
|
|
190
|
+
"evidence_level": loaded.manifest.evidence_level,
|
|
191
|
+
**(source or {}),
|
|
192
|
+
}
|
|
193
|
+
self._source_path(patch_name).write_text(
|
|
194
|
+
json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
195
|
+
)
|
|
196
|
+
return InstalledPatch(name=patch_name, path=target, source=record)
|
|
197
|
+
|
|
198
|
+
def install_from_hub(
|
|
199
|
+
self,
|
|
200
|
+
ref: str,
|
|
201
|
+
*,
|
|
202
|
+
filename: str | None = None,
|
|
203
|
+
revision: str | None = None,
|
|
204
|
+
overwrite: bool = False,
|
|
205
|
+
offline: bool = False,
|
|
206
|
+
) -> InstalledPatch:
|
|
207
|
+
"""Install from a Hugging Face repo reference.
|
|
208
|
+
|
|
209
|
+
``ref`` is ``owner/repo`` or ``owner/repo:path/to/file.brainpatch``.
|
|
210
|
+
Only the patch artifact is downloaded -- never the base model.
|
|
211
|
+
"""
|
|
212
|
+
match = _HUB_REF_RE.match(ref)
|
|
213
|
+
if not match:
|
|
214
|
+
raise RegistryError(
|
|
215
|
+
f"{ref!r} is not a valid Hugging Face reference. "
|
|
216
|
+
"Expected 'owner/repo' or 'owner/repo:file.brainpatch'."
|
|
217
|
+
)
|
|
218
|
+
repo = match.group("repo")
|
|
219
|
+
wanted = filename or match.group("file")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
from huggingface_hub import hf_hub_download, list_repo_files
|
|
223
|
+
except ModuleNotFoundError as exc: # pragma: no cover - depends on env
|
|
224
|
+
raise RegistryError(
|
|
225
|
+
"installing from Hugging Face needs the 'huggingface_hub' package.\n"
|
|
226
|
+
" pip install 'brainpatch[hub]'"
|
|
227
|
+
) from exc
|
|
228
|
+
|
|
229
|
+
if offline:
|
|
230
|
+
raise RegistryError(
|
|
231
|
+
"cannot install from Hugging Face in offline mode; "
|
|
232
|
+
"download the .brainpatch file separately and install it by path"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
self.ensure_dirs()
|
|
236
|
+
|
|
237
|
+
if wanted is None:
|
|
238
|
+
try:
|
|
239
|
+
files = list_repo_files(repo, revision=revision)
|
|
240
|
+
except Exception as exc: # noqa: BLE001 - hub raises many types
|
|
241
|
+
raise RegistryError(f"could not list files in {repo!r}: {exc}") from exc
|
|
242
|
+
candidates = [f for f in files if f.endswith(SUFFIX)]
|
|
243
|
+
if not candidates:
|
|
244
|
+
raise RegistryError(
|
|
245
|
+
f"{repo!r} contains no {SUFFIX} artifact. "
|
|
246
|
+
"Specify one explicitly with 'owner/repo:path/to/file.brainpatch'."
|
|
247
|
+
)
|
|
248
|
+
if len(candidates) > 1:
|
|
249
|
+
raise RegistryError(
|
|
250
|
+
f"{repo!r} contains several patches: {candidates}. "
|
|
251
|
+
"Choose one with 'owner/repo:<file>'."
|
|
252
|
+
)
|
|
253
|
+
wanted = candidates[0]
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
downloaded = hf_hub_download(
|
|
257
|
+
repo_id=repo,
|
|
258
|
+
filename=wanted,
|
|
259
|
+
revision=revision,
|
|
260
|
+
cache_dir=str(self.cache_dir),
|
|
261
|
+
)
|
|
262
|
+
except Exception as exc: # noqa: BLE001
|
|
263
|
+
raise RegistryError(f"could not download {wanted!r} from {repo!r}: {exc}") from exc
|
|
264
|
+
|
|
265
|
+
return self.install_file(
|
|
266
|
+
downloaded,
|
|
267
|
+
overwrite=overwrite,
|
|
268
|
+
source={"kind": "huggingface", "repo": repo, "file": wanted, "revision": revision},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def install(self, ref: str, *, overwrite: bool = False, offline: bool = False) -> InstalledPatch:
|
|
272
|
+
"""Install from a path or a Hugging Face reference, whichever ``ref`` is."""
|
|
273
|
+
candidate = Path(ref).expanduser()
|
|
274
|
+
if candidate.is_file():
|
|
275
|
+
return self.install_file(candidate, overwrite=overwrite)
|
|
276
|
+
if _HUB_REF_RE.match(ref):
|
|
277
|
+
return self.install_from_hub(ref, overwrite=overwrite, offline=offline)
|
|
278
|
+
raise RegistryError(
|
|
279
|
+
f"{ref!r} is neither an existing file nor an 'owner/repo' Hugging Face reference"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def uninstall(self, name: str) -> None:
|
|
283
|
+
patch = self.get(name)
|
|
284
|
+
patch.path.unlink()
|
|
285
|
+
source = self._source_path(name)
|
|
286
|
+
if source.exists():
|
|
287
|
+
source.unlink()
|
|
288
|
+
|
|
289
|
+
def clear_cache(self) -> int:
|
|
290
|
+
"""Delete the download cache. Returns bytes freed."""
|
|
291
|
+
if not self.cache_dir.is_dir():
|
|
292
|
+
return 0
|
|
293
|
+
freed = sum(p.stat().st_size for p in self.cache_dir.rglob("*") if p.is_file())
|
|
294
|
+
shutil.rmtree(self.cache_dir, ignore_errors=True)
|
|
295
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
return freed
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def default_registry() -> PatchRegistry:
|
|
300
|
+
return PatchRegistry()
|