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,167 @@
1
+ """Activation-extraction manifest.
2
+
3
+ The manifest is the single source of truth describing an activation corpus: what
4
+ model produced it, from which hook site, in what dtype, and which shards are
5
+ complete. Extraction is *resumable*: the manifest is rewritten after each shard
6
+ lands, and a restarted run reads it to decide where to continue.
7
+
8
+ Shards themselves are immutable. A shard file, once written and recorded in the
9
+ manifest, is never modified -- only appended to by new shards. This makes a
10
+ partially-failed extraction safe to resume without corrupting earlier work.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any
18
+
19
+ MANIFEST_FORMAT_VERSION = "0.1"
20
+
21
+
22
+ @dataclass
23
+ class ShardRecord:
24
+ """One immutable activation shard.
25
+
26
+ Attributes
27
+ ----------
28
+ index:
29
+ Zero-based shard number; determines the filename.
30
+ filename:
31
+ Basename on the Volume, e.g. ``shard_000003.safetensors``.
32
+ num_tokens:
33
+ Number of activation rows stored in this shard.
34
+ first_example:
35
+ Index (into ``examples.jsonl``) of the first example contributing rows.
36
+ last_example:
37
+ Index of the last example contributing rows, inclusive.
38
+ sha256:
39
+ Optional content hash, used to detect silent corruption on resume.
40
+ bytes:
41
+ On-disk size, used to compute real bytes-per-token for cost estimates.
42
+ """
43
+
44
+ index: int
45
+ filename: str
46
+ num_tokens: int
47
+ first_example: int
48
+ last_example: int
49
+ sha256: str | None = None
50
+ bytes: int | None = None
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return asdict(self)
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict[str, Any]) -> "ShardRecord":
57
+ return cls(
58
+ index=int(data["index"]),
59
+ filename=str(data["filename"]),
60
+ num_tokens=int(data["num_tokens"]),
61
+ first_example=int(data["first_example"]),
62
+ last_example=int(data["last_example"]),
63
+ sha256=data.get("sha256"),
64
+ bytes=data.get("bytes"),
65
+ )
66
+
67
+
68
+ @dataclass
69
+ class ActivationManifest:
70
+ """Complete description of one activation corpus.
71
+
72
+ Everything needed to (a) resume extraction, (b) stream the corpus into SAE
73
+ training, and (c) later verify that an SAE / patch is being applied to the
74
+ same hook site it was trained on.
75
+ """
76
+
77
+ experiment: str
78
+ model: str
79
+ model_revision: str
80
+ layer: int
81
+ hook: str
82
+ hidden_size: int
83
+ dtype: str
84
+ dataset: str
85
+ dataset_split: str
86
+ sequence_length: int
87
+ requested_tokens: int
88
+ completed_tokens: int = 0
89
+ shard_size: int = 100_000
90
+ seed: int = 0
91
+ shards: list[ShardRecord] = field(default_factory=list)
92
+ num_examples: int = 0
93
+ created_at: str | None = None
94
+ updated_at: str | None = None
95
+ format_version: str = MANIFEST_FORMAT_VERSION
96
+ #: Free-form provenance: package versions, GPU, git commit, timings.
97
+ provenance: dict[str, Any] = field(default_factory=dict)
98
+
99
+ # -- derived properties ----------------------------------------------------
100
+
101
+ @property
102
+ def is_complete(self) -> bool:
103
+ """True once at least ``requested_tokens`` activations have been stored."""
104
+ return self.completed_tokens >= self.requested_tokens
105
+
106
+ @property
107
+ def next_shard_index(self) -> int:
108
+ """Index the next shard should be written under."""
109
+ return len(self.shards)
110
+
111
+ @property
112
+ def bytes_per_token(self) -> float | None:
113
+ """Measured on-disk bytes per activation row, or ``None`` if unknown.
114
+
115
+ Used to produce *real* (not guessed) storage estimates for larger runs.
116
+ """
117
+ sized = [s for s in self.shards if s.bytes is not None]
118
+ if not sized:
119
+ return None
120
+ total_bytes = sum(s.bytes for s in sized) # type: ignore[misc]
121
+ total_tokens = sum(s.num_tokens for s in sized)
122
+ if total_tokens == 0:
123
+ return None
124
+ return total_bytes / total_tokens
125
+
126
+ # -- validation ------------------------------------------------------------
127
+
128
+ def validate(self) -> None:
129
+ """Raise :class:`ValueError` if the manifest is internally inconsistent."""
130
+ if self.hidden_size <= 0:
131
+ raise ValueError(f"hidden_size must be positive, got {self.hidden_size}")
132
+ if self.layer < 0:
133
+ raise ValueError(f"layer must be non-negative, got {self.layer}")
134
+ if self.sequence_length <= 0:
135
+ raise ValueError(f"sequence_length must be positive, got {self.sequence_length}")
136
+ if self.shard_size <= 0:
137
+ raise ValueError(f"shard_size must be positive, got {self.shard_size}")
138
+ expected = sum(s.num_tokens for s in self.shards)
139
+ if expected != self.completed_tokens:
140
+ raise ValueError(
141
+ "manifest is inconsistent: shards account for "
142
+ f"{expected} tokens but completed_tokens={self.completed_tokens}"
143
+ )
144
+ indices = [s.index for s in self.shards]
145
+ if indices != list(range(len(indices))):
146
+ raise ValueError(f"shard indices must be contiguous from 0, got {indices}")
147
+
148
+ # -- serialization ---------------------------------------------------------
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ data = asdict(self)
152
+ data["shards"] = [s.to_dict() for s in self.shards]
153
+ return data
154
+
155
+ def to_json(self, *, indent: int = 2) -> str:
156
+ return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
157
+
158
+ @classmethod
159
+ def from_dict(cls, data: dict[str, Any]) -> "ActivationManifest":
160
+ known = {f for f in cls.__dataclass_fields__} # noqa: SLF001
161
+ kwargs = {k: v for k, v in data.items() if k in known and k != "shards"}
162
+ kwargs["shards"] = [ShardRecord.from_dict(s) for s in data.get("shards", [])]
163
+ return cls(**kwargs) # type: ignore[arg-type]
164
+
165
+ @classmethod
166
+ def from_json(cls, text: str) -> "ActivationManifest":
167
+ return cls.from_dict(json.loads(text))
@@ -0,0 +1,379 @@
1
+ """The BrainPatch file format.
2
+
3
+ A BrainPatch is a tiny, inspectable, shareable JSON file describing an
4
+ activation-space intervention on a *specific* model at a *specific* hook site.
5
+
6
+ The format carries enough provenance to make misapplication a loud error rather
7
+ than a silent one. Applying a patch trained on Qwen's layer-18 residual stream
8
+ to a Gemma model, or to a different layer, or against a different SAE, produces
9
+ different arithmetic on unrelated directions -- it does not "sort of work". So
10
+ :meth:`BrainPatchSpec.check_compatibility` refuses all of those cases.
11
+
12
+ Example
13
+ -------
14
+ ::
15
+
16
+ {
17
+ "format_version": "0.1",
18
+ "name": "experimental-feature-1207",
19
+ "description": "Unvalidated single-feature steering direction.",
20
+ "base_model": "Qwen/Qwen2.5-1.5B-Instruct",
21
+ "model_revision": "989aa7980e4cf806f80c7fef2b1adb7bc71aa306",
22
+ "sae": {
23
+ "reference": "smoke_v0",
24
+ "layer": 18,
25
+ "hook": "residual_post",
26
+ "d_in": 1536,
27
+ "d_sae": 2048
28
+ },
29
+ "features": [{"feature_id": 1207, "strength": 1.5}],
30
+ "evidence_level": "interventional",
31
+ "evaluation": {},
32
+ "license": "Apache-2.0",
33
+ "authors": []
34
+ }
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ from dataclasses import asdict, dataclass, field
42
+ from typing import Any
43
+
44
+ from brainpatch.schemas.feature import CONTROLLED_LEVELS, EVIDENCE_ORDER, EvidenceLevel
45
+
46
+ PATCH_FORMAT_VERSION = "0.1"
47
+
48
+ #: Format versions this build knows how to load.
49
+ SUPPORTED_FORMAT_VERSIONS = frozenset({"0.1"})
50
+
51
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
52
+
53
+
54
+ class PatchValidationError(ValueError):
55
+ """The patch file is malformed or self-inconsistent."""
56
+
57
+
58
+ class PatchCompatibilityError(ValueError):
59
+ """The patch is well-formed but does not match the target model/SAE."""
60
+
61
+
62
+ @dataclass
63
+ class FeatureEdit:
64
+ """One feature direction to add to the residual stream.
65
+
66
+ ``strength`` is measured in units of ``input_scale`` along the unit-norm
67
+ decoder column (see :mod:`brainpatch.schemas.sae`). Positive amplifies,
68
+ negative suppresses. ``mode`` distinguishes plain addition from ablation,
69
+ which needs the SAE encoder at runtime rather than just the decoder column.
70
+ """
71
+
72
+ feature_id: int
73
+ strength: float
74
+ #: ``"add"`` injects ``strength * scale * d_f``.
75
+ #: ``"ablate"`` projects out the feature's *measured* contribution instead.
76
+ mode: str = "add"
77
+
78
+ def validate(self, *, d_sae: int | None = None) -> None:
79
+ if self.feature_id < 0:
80
+ raise PatchValidationError(f"feature_id must be non-negative, got {self.feature_id}")
81
+ if d_sae is not None and self.feature_id >= d_sae:
82
+ raise PatchValidationError(
83
+ f"feature_id {self.feature_id} is out of range for a dictionary of size {d_sae}"
84
+ )
85
+ if self.mode not in {"add", "ablate"}:
86
+ raise PatchValidationError(f"unknown feature edit mode {self.mode!r}")
87
+ if not isinstance(self.strength, (int, float)):
88
+ raise PatchValidationError(f"strength must be numeric, got {type(self.strength)}")
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ return asdict(self)
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: dict[str, Any]) -> "FeatureEdit":
95
+ if "feature_id" not in data:
96
+ raise PatchValidationError("feature edit is missing 'feature_id'")
97
+ if "strength" not in data:
98
+ raise PatchValidationError("feature edit is missing 'strength'")
99
+ return cls(
100
+ feature_id=int(data["feature_id"]),
101
+ strength=float(data["strength"]),
102
+ mode=str(data.get("mode", "add")),
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class SAEReference:
108
+ """Identifies the sparse autoencoder a patch's feature IDs refer to."""
109
+
110
+ reference: str
111
+ """Experiment name / repo path locating the SAE checkpoint."""
112
+ layer: int
113
+ hook: str
114
+ d_in: int
115
+ d_sae: int
116
+ #: Multiplier that maps normalized SAE space back to raw residual scale.
117
+ input_scale: float | None = None
118
+ sha256: str | None = None
119
+
120
+ def validate(self) -> None:
121
+ if not self.reference:
122
+ raise PatchValidationError("sae.reference must be a non-empty string")
123
+ if self.layer < 0:
124
+ raise PatchValidationError(f"sae.layer must be non-negative, got {self.layer}")
125
+ if not self.hook:
126
+ raise PatchValidationError("sae.hook must be a non-empty string")
127
+ if self.d_in <= 0 or self.d_sae <= 0:
128
+ raise PatchValidationError("sae.d_in and sae.d_sae must be positive")
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ return asdict(self)
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict[str, Any]) -> "SAEReference":
135
+ missing = {"reference", "layer", "hook", "d_in", "d_sae"} - set(data)
136
+ if missing:
137
+ raise PatchValidationError(f"sae block is missing keys: {sorted(missing)}")
138
+ return cls(
139
+ reference=str(data["reference"]),
140
+ layer=int(data["layer"]),
141
+ hook=str(data["hook"]),
142
+ d_in=int(data["d_in"]),
143
+ d_sae=int(data["d_sae"]),
144
+ input_scale=(None if data.get("input_scale") is None else float(data["input_scale"])),
145
+ sha256=data.get("sha256"),
146
+ )
147
+
148
+
149
+ @dataclass
150
+ class BrainPatchSpec:
151
+ """A complete, portable BrainPatch definition."""
152
+
153
+ name: str
154
+ base_model: str
155
+ sae: SAEReference
156
+ features: list[FeatureEdit]
157
+ description: str = ""
158
+ model_revision: str | None = None
159
+ #: Optional token-index -> strength-multiplier schedule for dynamic steering.
160
+ schedule: dict[str, float] | None = None
161
+ #: Measured results. Empty dict means "not evaluated", never "it works".
162
+ evaluation: dict[str, Any] = field(default_factory=dict)
163
+ evidence_level: EvidenceLevel = "none"
164
+ license: str = "Apache-2.0"
165
+ authors: list[str] = field(default_factory=list)
166
+ format_version: str = PATCH_FORMAT_VERSION
167
+ metadata: dict[str, Any] = field(default_factory=dict)
168
+
169
+ # -- validation ------------------------------------------------------------
170
+
171
+ def validate(self) -> None:
172
+ """Raise :class:`PatchValidationError` if this patch is malformed."""
173
+ if self.format_version not in SUPPORTED_FORMAT_VERSIONS:
174
+ raise PatchValidationError(
175
+ f"unsupported format_version {self.format_version!r}; "
176
+ f"this build supports {sorted(SUPPORTED_FORMAT_VERSIONS)}"
177
+ )
178
+ if not _NAME_RE.match(self.name):
179
+ raise PatchValidationError(
180
+ f"patch name {self.name!r} must be lowercase alphanumeric with "
181
+ "'.', '_' or '-', 1-64 characters"
182
+ )
183
+ if not self.base_model:
184
+ raise PatchValidationError("base_model must be a non-empty string")
185
+ if self.evidence_level not in EVIDENCE_ORDER:
186
+ raise PatchValidationError(
187
+ f"unknown evidence_level {self.evidence_level!r}; expected one of {EVIDENCE_ORDER}"
188
+ )
189
+ self.sae.validate()
190
+ if not self.features:
191
+ raise PatchValidationError("a patch must edit at least one feature")
192
+
193
+ seen: set[int] = set()
194
+ for edit in self.features:
195
+ edit.validate(d_sae=self.sae.d_sae)
196
+ if edit.feature_id in seen:
197
+ raise PatchValidationError(
198
+ f"feature_id {edit.feature_id} appears more than once; "
199
+ "merge the edits into a single entry"
200
+ )
201
+ seen.add(edit.feature_id)
202
+
203
+ if self.schedule is not None:
204
+ self._validate_schedule()
205
+
206
+ def _validate_schedule(self) -> None:
207
+ assert self.schedule is not None
208
+ if not self.schedule:
209
+ raise PatchValidationError("schedule, if present, must be non-empty")
210
+ for key, value in self.schedule.items():
211
+ try:
212
+ step = int(key)
213
+ except (TypeError, ValueError) as exc:
214
+ raise PatchValidationError(
215
+ f"schedule keys must be integer token indices, got {key!r}"
216
+ ) from exc
217
+ if step < 0:
218
+ raise PatchValidationError(f"schedule token index must be >= 0, got {step}")
219
+ if not isinstance(value, (int, float)):
220
+ raise PatchValidationError(
221
+ f"schedule value for step {step} must be numeric, got {value!r}"
222
+ )
223
+
224
+ # -- compatibility ---------------------------------------------------------
225
+
226
+ def check_compatibility(
227
+ self,
228
+ *,
229
+ model: str,
230
+ hidden_size: int,
231
+ num_layers: int | None = None,
232
+ model_revision: str | None = None,
233
+ sae_reference: str | None = None,
234
+ sae_d_sae: int | None = None,
235
+ strict_revision: bool = False,
236
+ ) -> None:
237
+ """Refuse to apply this patch to an incompatible target.
238
+
239
+ Parameters
240
+ ----------
241
+ model:
242
+ Hugging Face id of the model the patch is about to be applied to.
243
+ hidden_size:
244
+ Residual width of that model.
245
+ num_layers:
246
+ Layer count, used to reject an out-of-range hook layer.
247
+ model_revision:
248
+ Revision actually loaded. Compared against the patch's recorded
249
+ revision when both are known.
250
+ sae_reference, sae_d_sae:
251
+ Identity of the SAE currently loaded in the runtime.
252
+ strict_revision:
253
+ When True a revision mismatch is an error rather than tolerated.
254
+ Feature directions are properties of a specific set of weights, so
255
+ production use should set this.
256
+
257
+ Raises
258
+ ------
259
+ PatchCompatibilityError
260
+ On any mismatch.
261
+ """
262
+ if model != self.base_model:
263
+ raise PatchCompatibilityError(
264
+ f"patch {self.name!r} targets base model {self.base_model!r} "
265
+ f"but was applied to {model!r}. Feature directions are not "
266
+ "transferable between models."
267
+ )
268
+ if hidden_size != self.sae.d_in:
269
+ raise PatchCompatibilityError(
270
+ f"patch {self.name!r} expects hidden size {self.sae.d_in} "
271
+ f"but the model has {hidden_size}"
272
+ )
273
+ if num_layers is not None and self.sae.layer >= num_layers:
274
+ raise PatchCompatibilityError(
275
+ f"patch {self.name!r} hooks layer {self.sae.layer} but the model "
276
+ f"only has {num_layers} layers"
277
+ )
278
+ if (
279
+ strict_revision
280
+ and self.model_revision is not None
281
+ and model_revision is not None
282
+ and model_revision != self.model_revision
283
+ ):
284
+ raise PatchCompatibilityError(
285
+ f"patch {self.name!r} was derived from revision "
286
+ f"{self.model_revision!r} but revision {model_revision!r} is loaded"
287
+ )
288
+ if sae_reference is not None and sae_reference != self.sae.reference:
289
+ raise PatchCompatibilityError(
290
+ f"patch {self.name!r} refers to SAE {self.sae.reference!r} "
291
+ f"but SAE {sae_reference!r} is loaded; feature IDs are not "
292
+ "comparable across SAEs"
293
+ )
294
+ if sae_d_sae is not None and sae_d_sae != self.sae.d_sae:
295
+ raise PatchCompatibilityError(
296
+ f"patch {self.name!r} expects a dictionary of size {self.sae.d_sae} "
297
+ f"but the loaded SAE has {sae_d_sae}"
298
+ )
299
+
300
+ # -- convenience -----------------------------------------------------------
301
+
302
+ @property
303
+ def has_controlled_evidence(self) -> bool:
304
+ """True once scale-matched controls have been run and passed."""
305
+ return self.evidence_level in CONTROLLED_LEVELS
306
+
307
+ @property
308
+ def is_validated(self) -> bool:
309
+ """True only for a controlled result that survived independent repetition.
310
+
311
+ One passing controlled experiment is ``controlled_interventional``. It
312
+ takes a replication to be called validated.
313
+ """
314
+ return self.evidence_level == "replicated"
315
+
316
+ def summary(self) -> str:
317
+ """One-line human summary that does not overstate evidence."""
318
+ edits = ", ".join(f"#{e.feature_id}@{e.strength:+.2f}" for e in self.features)
319
+ status = "validated" if self.is_validated else f"{self.evidence_level}/unvalidated"
320
+ return f"{self.name} [{status}] L{self.sae.layer} {edits}"
321
+
322
+ # -- serialization ---------------------------------------------------------
323
+
324
+ def to_dict(self) -> dict[str, Any]:
325
+ return {
326
+ "format_version": self.format_version,
327
+ "name": self.name,
328
+ "description": self.description,
329
+ "base_model": self.base_model,
330
+ "model_revision": self.model_revision,
331
+ "sae": self.sae.to_dict(),
332
+ "features": [e.to_dict() for e in self.features],
333
+ "schedule": self.schedule,
334
+ "evaluation": self.evaluation,
335
+ "evidence_level": self.evidence_level,
336
+ "license": self.license,
337
+ "authors": list(self.authors),
338
+ "metadata": self.metadata,
339
+ }
340
+
341
+ def to_json(self, *, indent: int = 2) -> str:
342
+ return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
343
+
344
+ @classmethod
345
+ def from_dict(cls, data: dict[str, Any]) -> "BrainPatchSpec":
346
+ """Parse and validate a patch dictionary."""
347
+ if not isinstance(data, dict):
348
+ raise PatchValidationError(f"patch must be a JSON object, got {type(data).__name__}")
349
+ for key in ("name", "base_model", "sae", "features"):
350
+ if key not in data:
351
+ raise PatchValidationError(f"patch is missing required key {key!r}")
352
+ if not isinstance(data["features"], list):
353
+ raise PatchValidationError("'features' must be a list")
354
+
355
+ spec = cls(
356
+ name=str(data["name"]),
357
+ base_model=str(data["base_model"]),
358
+ sae=SAEReference.from_dict(data["sae"]),
359
+ features=[FeatureEdit.from_dict(f) for f in data["features"]],
360
+ description=str(data.get("description", "")),
361
+ model_revision=data.get("model_revision"),
362
+ schedule=data.get("schedule"),
363
+ evaluation=dict(data.get("evaluation", {})),
364
+ evidence_level=data.get("evidence_level", "none"),
365
+ license=str(data.get("license", "Apache-2.0")),
366
+ authors=list(data.get("authors", [])),
367
+ format_version=str(data.get("format_version", PATCH_FORMAT_VERSION)),
368
+ metadata=dict(data.get("metadata", {})),
369
+ )
370
+ spec.validate()
371
+ return spec
372
+
373
+ @classmethod
374
+ def from_json(cls, text: str) -> "BrainPatchSpec":
375
+ try:
376
+ data = json.loads(text)
377
+ except json.JSONDecodeError as exc:
378
+ raise PatchValidationError(f"patch is not valid JSON: {exc}") from exc
379
+ return cls.from_dict(data)
@@ -0,0 +1,88 @@
1
+ """Filesystem I/O for BrainPatch files.
2
+
3
+ Patches are small JSON documents. They are read and written identically on a
4
+ laptop and inside a Modal container, so this module stays on the standard
5
+ library.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from brainpatch.schemas.patch import BrainPatchSpec, PatchValidationError
14
+
15
+ PATCH_SUFFIX = ".json"
16
+
17
+
18
+ def load_patch(path: str | os.PathLike[str]) -> BrainPatchSpec:
19
+ """Load and validate a single patch file.
20
+
21
+ Raises
22
+ ------
23
+ FileNotFoundError
24
+ If the path does not exist.
25
+ PatchValidationError
26
+ If the file is not a well-formed patch.
27
+ """
28
+ p = Path(path)
29
+ if not p.is_file():
30
+ raise FileNotFoundError(f"patch file not found: {p}")
31
+ try:
32
+ return BrainPatchSpec.from_json(p.read_text(encoding="utf-8"))
33
+ except PatchValidationError as exc:
34
+ raise PatchValidationError(f"{p}: {exc}") from exc
35
+
36
+
37
+ def dump_patch(spec: BrainPatchSpec) -> str:
38
+ """Serialize a patch to its canonical JSON representation."""
39
+ spec.validate()
40
+ return spec.to_json() + "\n"
41
+
42
+
43
+ def save_patch(spec: BrainPatchSpec, path: str | os.PathLike[str], *, overwrite: bool = False) -> Path:
44
+ """Write a patch to disk, refusing to clobber unless ``overwrite``."""
45
+ p = Path(path)
46
+ if p.exists() and not overwrite:
47
+ raise FileExistsError(f"{p} already exists; pass overwrite=True to replace it")
48
+ p.parent.mkdir(parents=True, exist_ok=True)
49
+ p.write_text(dump_patch(spec), encoding="utf-8")
50
+ return p
51
+
52
+
53
+ def discover_patches(directory: str | os.PathLike[str]) -> list[Path]:
54
+ """List patch files in ``directory``, sorted by name. Missing dir -> []."""
55
+ d = Path(directory)
56
+ if not d.is_dir():
57
+ return []
58
+ return sorted(p for p in d.glob(f"*{PATCH_SUFFIX}") if p.is_file())
59
+
60
+
61
+ def load_patch_dir(
62
+ directory: str | os.PathLike[str], *, strict: bool = True
63
+ ) -> tuple[list[BrainPatchSpec], list[tuple[Path, str]]]:
64
+ """Load every patch in a directory.
65
+
66
+ Parameters
67
+ ----------
68
+ strict:
69
+ When True (default) the first malformed patch raises. When False,
70
+ failures are collected and returned so a CLI listing can show the
71
+ healthy patches alongside the broken ones.
72
+
73
+ Returns
74
+ -------
75
+ (specs, failures)
76
+ ``failures`` is a list of ``(path, message)`` pairs, always empty when
77
+ ``strict`` is True.
78
+ """
79
+ specs: list[BrainPatchSpec] = []
80
+ failures: list[tuple[Path, str]] = []
81
+ for path in discover_patches(directory):
82
+ try:
83
+ specs.append(load_patch(path))
84
+ except (PatchValidationError, OSError) as exc:
85
+ if strict:
86
+ raise
87
+ failures.append((path, str(exc)))
88
+ return specs, failures