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