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,489 @@
|
|
|
1
|
+
"""BrainPatch v1: the portable, self-contained runtime format.
|
|
2
|
+
|
|
3
|
+
What changed from v0.1, and why
|
|
4
|
+
-------------------------------
|
|
5
|
+
The v0.1 patch (``brainpatch.schemas.patch``) is a *research* record: it names
|
|
6
|
+
SAE feature IDs and their coefficients. To apply it you need the SAE that
|
|
7
|
+
produced those IDs -- a 72 MB checkpoint to use three directions. That is fine
|
|
8
|
+
for reproducing an experiment and wrong for shipping a product.
|
|
9
|
+
|
|
10
|
+
v1 stores the **materialised intervention vectors** instead. The runtime adds a
|
|
11
|
+
vector to a layer's residual stream; it neither knows nor cares whether that
|
|
12
|
+
vector came from an SAE decoder column, a difference of means, a PCA component,
|
|
13
|
+
or a learned controller. Research provenance is preserved in metadata, but
|
|
14
|
+
nothing at runtime depends on it.
|
|
15
|
+
|
|
16
|
+
Consequences that matter:
|
|
17
|
+
|
|
18
|
+
* a three-direction patch is tens of KB, not tens of MB
|
|
19
|
+
* no SAE download, no Modal, no network at apply time
|
|
20
|
+
* the same artifact drives every backend
|
|
21
|
+
|
|
22
|
+
Container layout
|
|
23
|
+
----------------
|
|
24
|
+
A ``.brainpatch`` file is a ZIP archive::
|
|
25
|
+
|
|
26
|
+
manifest.json this module's schema, the only thing that is parsed
|
|
27
|
+
vectors.safetensors the intervention vectors (inert data)
|
|
28
|
+
checksums.json sha256 of every other member
|
|
29
|
+
README.md optional human-readable description
|
|
30
|
+
|
|
31
|
+
Deliberately **not** in the format: pickles, executable code, scripts, or
|
|
32
|
+
anything the runtime evaluates. A patch is data. See
|
|
33
|
+
:mod:`brainpatch.patch.validation` for the checks that keep it that way.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from brainpatch.schemas.feature import EVIDENCE_ORDER, EvidenceLevel
|
|
44
|
+
|
|
45
|
+
FORMAT_VERSION = "1.0"
|
|
46
|
+
SUPPORTED_FORMAT_VERSIONS = frozenset({"1.0"})
|
|
47
|
+
|
|
48
|
+
MANIFEST_NAME = "manifest.json"
|
|
49
|
+
VECTORS_NAME = "vectors.safetensors"
|
|
50
|
+
CHECKSUMS_NAME = "checksums.json"
|
|
51
|
+
README_NAME = "README.md"
|
|
52
|
+
|
|
53
|
+
#: File extension for a compiled runtime artifact.
|
|
54
|
+
SUFFIX = ".brainpatch"
|
|
55
|
+
|
|
56
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
57
|
+
|
|
58
|
+
#: Hook sites the runtime knows how to apply. ``residual_post`` is the output of
|
|
59
|
+
#: decoder block *i* -- where a block's contribution is visible and where an
|
|
60
|
+
#: injection propagates to every later block.
|
|
61
|
+
SUPPORTED_HOOKS = ("residual_post",)
|
|
62
|
+
|
|
63
|
+
#: Verification states a backend entry may claim. "supported" is deliberately
|
|
64
|
+
#: absent: a backend is either verified by a real test or it is not.
|
|
65
|
+
COMPATIBILITY_STATES = ("verified", "experimental", "implemented", "unsupported")
|
|
66
|
+
|
|
67
|
+
#: Absolute ceiling on any coefficient, regardless of what a manifest asks for.
|
|
68
|
+
#: A patch is untrusted input; an enormous coefficient is a denial-of-service
|
|
69
|
+
#: on output quality at best.
|
|
70
|
+
ABSOLUTE_MAX_STRENGTH = 1024.0
|
|
71
|
+
|
|
72
|
+
#: How a direction was found. Recorded because it turned out to matter: on the
|
|
73
|
+
#: one behavioural task measured so far, PCA, a linear probe, difference-of-means
|
|
74
|
+
#: and an SAE feature produced very different directions from identical data, and
|
|
75
|
+
#: a patch that does not say which it used cannot be compared with another.
|
|
76
|
+
KNOWN_DISCOVERY_METHODS = (
|
|
77
|
+
"caa",
|
|
78
|
+
"pca",
|
|
79
|
+
"probe",
|
|
80
|
+
"sae_single",
|
|
81
|
+
"sae_sparse",
|
|
82
|
+
"handwritten",
|
|
83
|
+
"other",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
#: Where the direction was read off during discovery.
|
|
87
|
+
KNOWN_EXTRACTION_POSITIONS = ("last_prompt", "cont_mean", "cont_last", "all_tokens", "other")
|
|
88
|
+
|
|
89
|
+
#: Where the runtime adds it. This is not cosmetic: measured effect differed by
|
|
90
|
+
#: roughly 6x between steering prompt tokens and steering generated tokens.
|
|
91
|
+
KNOWN_INJECTION_SITES = ("prompt", "continuation", "all")
|
|
92
|
+
|
|
93
|
+
#: Provenance is documentation, not payload. The cap is what stops a patch from
|
|
94
|
+
#: carrying its training set: a few kilobytes of description is provenance, a
|
|
95
|
+
#: megabyte of it is a dataset with extra steps.
|
|
96
|
+
PROVENANCE_MAX_BYTES = 16_384
|
|
97
|
+
|
|
98
|
+
#: Keys that would smuggle example text into an artifact. A patch ships a
|
|
99
|
+
#: direction and the facts needed to audit it -- never the data it was fit on.
|
|
100
|
+
FORBIDDEN_PROVENANCE_KEYS = frozenset(
|
|
101
|
+
{"examples", "prompts", "dataset", "training_data", "samples", "corpus", "responses"}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_provenance(provenance: dict[str, Any]) -> None:
|
|
106
|
+
"""Check the optional provenance block.
|
|
107
|
+
|
|
108
|
+
Every field is optional -- an older patch with an empty block stays valid --
|
|
109
|
+
but a field that *is* present has to mean what it says. A misrecorded layer
|
|
110
|
+
or discovery method is worse than an absent one, because it looks like
|
|
111
|
+
an audit trail.
|
|
112
|
+
"""
|
|
113
|
+
if not provenance:
|
|
114
|
+
return
|
|
115
|
+
if not isinstance(provenance, dict):
|
|
116
|
+
raise PatchFormatError("provenance must be an object")
|
|
117
|
+
|
|
118
|
+
offending = sorted(FORBIDDEN_PROVENANCE_KEYS & {str(k).lower() for k in provenance})
|
|
119
|
+
if offending:
|
|
120
|
+
raise PatchFormatError(
|
|
121
|
+
f"provenance may not carry training data; remove {offending}. A patch "
|
|
122
|
+
"records how it was made, not what it was made from."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
encoded = len(json.dumps(provenance, ensure_ascii=False).encode("utf-8"))
|
|
126
|
+
if encoded > PROVENANCE_MAX_BYTES:
|
|
127
|
+
raise PatchFormatError(
|
|
128
|
+
f"provenance is {encoded} bytes, over the {PROVENANCE_MAX_BYTES} byte cap"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
method = provenance.get("discovery_method")
|
|
132
|
+
if method is not None and method not in KNOWN_DISCOVERY_METHODS:
|
|
133
|
+
raise PatchFormatError(
|
|
134
|
+
f"unknown discovery_method {method!r}; expected one of {KNOWN_DISCOVERY_METHODS}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
position = provenance.get("extraction_position")
|
|
138
|
+
if position is not None and position not in KNOWN_EXTRACTION_POSITIONS:
|
|
139
|
+
raise PatchFormatError(
|
|
140
|
+
f"unknown extraction_position {position!r}; "
|
|
141
|
+
f"expected one of {KNOWN_EXTRACTION_POSITIONS}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
site = provenance.get("injection_site")
|
|
145
|
+
if site is not None and site not in KNOWN_INJECTION_SITES:
|
|
146
|
+
raise PatchFormatError(
|
|
147
|
+
f"unknown injection_site {site!r}; expected one of {KNOWN_INJECTION_SITES}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
digest = provenance.get("training_dataset_hash")
|
|
151
|
+
if digest is not None:
|
|
152
|
+
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
|
153
|
+
raise PatchFormatError(
|
|
154
|
+
"training_dataset_hash must be a lowercase hex sha256 digest, so a "
|
|
155
|
+
f"reader can check which data produced this direction; got {digest!r}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
layer = provenance.get("discovery_layer")
|
|
159
|
+
if layer is not None and (not isinstance(layer, int) or isinstance(layer, bool) or layer < 0):
|
|
160
|
+
raise PatchFormatError(f"discovery_layer must be a non-negative integer, got {layer!r}")
|
|
161
|
+
|
|
162
|
+
calibration = provenance.get("strength_calibration")
|
|
163
|
+
if calibration is not None and not isinstance(calibration, dict):
|
|
164
|
+
raise PatchFormatError("strength_calibration must be an object")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PatchFormatError(ValueError):
|
|
168
|
+
"""The artifact is malformed, unsupported, or internally inconsistent."""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class Intervention:
|
|
173
|
+
"""One vector added to one layer's residual stream.
|
|
174
|
+
|
|
175
|
+
Attributes
|
|
176
|
+
----------
|
|
177
|
+
vector:
|
|
178
|
+
Key into ``vectors.safetensors``. Several interventions may reference
|
|
179
|
+
the same key.
|
|
180
|
+
coefficient:
|
|
181
|
+
Baked-in scale. The runtime multiplies this by the user's live strength
|
|
182
|
+
and by any schedule multiplier.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
layer: int
|
|
186
|
+
vector: str
|
|
187
|
+
coefficient: float = 1.0
|
|
188
|
+
hook: str = "residual_post"
|
|
189
|
+
id: str | None = None
|
|
190
|
+
#: Which token positions receive the vector. Added because a validated
|
|
191
|
+
#: research configuration turned out not to be expressible otherwise: v3's
|
|
192
|
+
#: result was measured with prompt-token-only injection, and a schedule
|
|
193
|
+
#: cannot encode that (the prompt pass and generated token 0 share an
|
|
194
|
+
#: index). Defaults to "all", so every previously written patch keeps its
|
|
195
|
+
#: exact meaning.
|
|
196
|
+
site: str = "all"
|
|
197
|
+
|
|
198
|
+
def validate(self, *, num_layers: int | None = None) -> None:
|
|
199
|
+
if self.layer < 0:
|
|
200
|
+
raise PatchFormatError(f"layer must be non-negative, got {self.layer}")
|
|
201
|
+
if num_layers is not None and self.layer >= num_layers:
|
|
202
|
+
raise PatchFormatError(
|
|
203
|
+
f"intervention targets layer {self.layer} but the patch declares "
|
|
204
|
+
f"{num_layers} layers"
|
|
205
|
+
)
|
|
206
|
+
if self.hook not in SUPPORTED_HOOKS:
|
|
207
|
+
raise PatchFormatError(
|
|
208
|
+
f"unsupported hook {self.hook!r}; supported: {SUPPORTED_HOOKS}"
|
|
209
|
+
)
|
|
210
|
+
if not isinstance(self.coefficient, (int, float)) or isinstance(self.coefficient, bool):
|
|
211
|
+
raise PatchFormatError(f"coefficient must be numeric, got {self.coefficient!r}")
|
|
212
|
+
if abs(self.coefficient) > ABSOLUTE_MAX_STRENGTH:
|
|
213
|
+
raise PatchFormatError(
|
|
214
|
+
f"coefficient {self.coefficient} exceeds the absolute ceiling "
|
|
215
|
+
f"of {ABSOLUTE_MAX_STRENGTH}"
|
|
216
|
+
)
|
|
217
|
+
if not self.vector:
|
|
218
|
+
raise PatchFormatError("intervention is missing a vector reference")
|
|
219
|
+
if self.site not in KNOWN_INJECTION_SITES:
|
|
220
|
+
raise PatchFormatError(
|
|
221
|
+
f"unknown injection site {self.site!r}; expected one of "
|
|
222
|
+
f"{KNOWN_INJECTION_SITES}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def to_dict(self) -> dict[str, Any]:
|
|
226
|
+
data: dict[str, Any] = {
|
|
227
|
+
"layer": self.layer,
|
|
228
|
+
"hook": self.hook,
|
|
229
|
+
"vector": self.vector,
|
|
230
|
+
"coefficient": self.coefficient,
|
|
231
|
+
}
|
|
232
|
+
if self.site != "all":
|
|
233
|
+
data["site"] = self.site
|
|
234
|
+
if self.id:
|
|
235
|
+
data["id"] = self.id
|
|
236
|
+
return data
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
def from_dict(cls, data: dict[str, Any]) -> "Intervention":
|
|
240
|
+
for key in ("layer", "vector"):
|
|
241
|
+
if key not in data:
|
|
242
|
+
raise PatchFormatError(f"intervention is missing required key {key!r}")
|
|
243
|
+
return cls(
|
|
244
|
+
layer=int(data["layer"]),
|
|
245
|
+
vector=str(data["vector"]),
|
|
246
|
+
coefficient=float(data.get("coefficient", 1.0)),
|
|
247
|
+
site=str(data.get("site", "all")),
|
|
248
|
+
hook=str(data.get("hook", "residual_post")),
|
|
249
|
+
id=data.get("id"),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class BaseModelSpec:
|
|
255
|
+
"""Which model this patch was derived from, and how strictly to enforce it.
|
|
256
|
+
|
|
257
|
+
Same architecture does not imply compatible directions: a vector found in
|
|
258
|
+
one model's layer-18 basis means nothing in another's. The runtime's default
|
|
259
|
+
``strict`` mode requires the model id to match.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
model_id: str
|
|
263
|
+
architecture: str = ""
|
|
264
|
+
hidden_size: int = 0
|
|
265
|
+
num_layers: int = 0
|
|
266
|
+
revision: str | None = None
|
|
267
|
+
torch_dtype: str | None = None
|
|
268
|
+
|
|
269
|
+
def validate(self) -> None:
|
|
270
|
+
if not self.model_id:
|
|
271
|
+
raise PatchFormatError("base_model.model_id must be non-empty")
|
|
272
|
+
if self.hidden_size <= 0:
|
|
273
|
+
raise PatchFormatError(f"base_model.hidden_size must be positive, got {self.hidden_size}")
|
|
274
|
+
if self.num_layers <= 0:
|
|
275
|
+
raise PatchFormatError(f"base_model.num_layers must be positive, got {self.num_layers}")
|
|
276
|
+
|
|
277
|
+
def to_dict(self) -> dict[str, Any]:
|
|
278
|
+
return {
|
|
279
|
+
"model_id": self.model_id,
|
|
280
|
+
"revision": self.revision,
|
|
281
|
+
"architecture": self.architecture,
|
|
282
|
+
"hidden_size": self.hidden_size,
|
|
283
|
+
"num_layers": self.num_layers,
|
|
284
|
+
"torch_dtype": self.torch_dtype,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def from_dict(cls, data: dict[str, Any]) -> "BaseModelSpec":
|
|
289
|
+
if "model_id" not in data:
|
|
290
|
+
raise PatchFormatError("base_model is missing 'model_id'")
|
|
291
|
+
return cls(
|
|
292
|
+
model_id=str(data["model_id"]),
|
|
293
|
+
architecture=str(data.get("architecture", "")),
|
|
294
|
+
hidden_size=int(data.get("hidden_size", 0)),
|
|
295
|
+
num_layers=int(data.get("num_layers", 0)),
|
|
296
|
+
revision=data.get("revision"),
|
|
297
|
+
torch_dtype=data.get("torch_dtype"),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class Manifest:
|
|
303
|
+
"""The parsed ``manifest.json`` of a v1 artifact."""
|
|
304
|
+
|
|
305
|
+
name: str
|
|
306
|
+
base_model: BaseModelSpec
|
|
307
|
+
interventions: list[Intervention]
|
|
308
|
+
description: str = ""
|
|
309
|
+
format_version: str = FORMAT_VERSION
|
|
310
|
+
evidence_level: EvidenceLevel = "none"
|
|
311
|
+
#: Measured results. ``{}`` means "not evaluated", never "it works".
|
|
312
|
+
evaluation: dict[str, Any] = field(default_factory=dict)
|
|
313
|
+
#: Per-backend verification state; see :data:`COMPATIBILITY_STATES`.
|
|
314
|
+
compatibility: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
315
|
+
#: How this patch was produced. Runtime never reads this.
|
|
316
|
+
provenance: dict[str, Any] = field(default_factory=dict)
|
|
317
|
+
#: Author-declared safety envelope for the live strength multiplier.
|
|
318
|
+
max_abs_strength: float = 8.0
|
|
319
|
+
default_strength: float = 1.0
|
|
320
|
+
license: str = "Apache-2.0"
|
|
321
|
+
authors: list[str] = field(default_factory=list)
|
|
322
|
+
#: Optional token-indexed schedule shipped with the patch.
|
|
323
|
+
schedule: dict[str, float] | None = None
|
|
324
|
+
|
|
325
|
+
# -- validation ------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def validate(self) -> None:
|
|
328
|
+
if self.format_version not in SUPPORTED_FORMAT_VERSIONS:
|
|
329
|
+
raise PatchFormatError(
|
|
330
|
+
f"unsupported format_version {self.format_version!r}; this build "
|
|
331
|
+
f"supports {sorted(SUPPORTED_FORMAT_VERSIONS)}"
|
|
332
|
+
)
|
|
333
|
+
if not _NAME_RE.match(self.name):
|
|
334
|
+
raise PatchFormatError(
|
|
335
|
+
f"patch name {self.name!r} must be lowercase alphanumeric with "
|
|
336
|
+
"'.', '_' or '-', 1-64 characters"
|
|
337
|
+
)
|
|
338
|
+
if self.evidence_level not in EVIDENCE_ORDER:
|
|
339
|
+
raise PatchFormatError(
|
|
340
|
+
f"unknown evidence_level {self.evidence_level!r}; "
|
|
341
|
+
f"expected one of {EVIDENCE_ORDER}"
|
|
342
|
+
)
|
|
343
|
+
self.base_model.validate()
|
|
344
|
+
|
|
345
|
+
if not self.interventions:
|
|
346
|
+
raise PatchFormatError("a patch must declare at least one intervention")
|
|
347
|
+
|
|
348
|
+
seen_ids: set[str] = set()
|
|
349
|
+
for intervention in self.interventions:
|
|
350
|
+
intervention.validate(num_layers=self.base_model.num_layers)
|
|
351
|
+
if intervention.id:
|
|
352
|
+
if intervention.id in seen_ids:
|
|
353
|
+
raise PatchFormatError(
|
|
354
|
+
f"duplicate intervention id {intervention.id!r}"
|
|
355
|
+
)
|
|
356
|
+
seen_ids.add(intervention.id)
|
|
357
|
+
|
|
358
|
+
if not 0 < self.max_abs_strength <= ABSOLUTE_MAX_STRENGTH:
|
|
359
|
+
raise PatchFormatError(
|
|
360
|
+
f"max_abs_strength must be in (0, {ABSOLUTE_MAX_STRENGTH}], "
|
|
361
|
+
f"got {self.max_abs_strength}"
|
|
362
|
+
)
|
|
363
|
+
if abs(self.default_strength) > self.max_abs_strength:
|
|
364
|
+
raise PatchFormatError(
|
|
365
|
+
f"default_strength {self.default_strength} exceeds the patch's own "
|
|
366
|
+
f"max_abs_strength {self.max_abs_strength}"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
validate_provenance(self.provenance)
|
|
370
|
+
|
|
371
|
+
for backend, entry in self.compatibility.items():
|
|
372
|
+
status = entry.get("status")
|
|
373
|
+
if status not in COMPATIBILITY_STATES:
|
|
374
|
+
raise PatchFormatError(
|
|
375
|
+
f"compatibility[{backend!r}].status must be one of "
|
|
376
|
+
f"{COMPATIBILITY_STATES}, got {status!r}"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if self.schedule is not None:
|
|
380
|
+
self._validate_schedule()
|
|
381
|
+
|
|
382
|
+
def _validate_schedule(self) -> None:
|
|
383
|
+
assert self.schedule is not None
|
|
384
|
+
if not self.schedule:
|
|
385
|
+
raise PatchFormatError("schedule, if present, must be non-empty")
|
|
386
|
+
for key, value in self.schedule.items():
|
|
387
|
+
try:
|
|
388
|
+
step = int(key)
|
|
389
|
+
except (TypeError, ValueError) as exc:
|
|
390
|
+
raise PatchFormatError(
|
|
391
|
+
f"schedule keys must be integer token indices, got {key!r}"
|
|
392
|
+
) from exc
|
|
393
|
+
if step < 0:
|
|
394
|
+
raise PatchFormatError(f"schedule token index must be >= 0, got {step}")
|
|
395
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
396
|
+
raise PatchFormatError(
|
|
397
|
+
f"schedule value at {step} must be numeric, got {value!r}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# -- queries ---------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def layers(self) -> list[int]:
|
|
404
|
+
"""Sorted distinct layers this patch touches."""
|
|
405
|
+
return sorted({i.layer for i in self.interventions})
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def vector_keys(self) -> list[str]:
|
|
409
|
+
return sorted({i.vector for i in self.interventions})
|
|
410
|
+
|
|
411
|
+
def backend_status(self, backend: str) -> str:
|
|
412
|
+
"""Verification state for ``backend``; ``"unsupported"`` if unlisted."""
|
|
413
|
+
return str(self.compatibility.get(backend, {}).get("status", "unsupported"))
|
|
414
|
+
|
|
415
|
+
def is_verified_on(self, backend: str) -> bool:
|
|
416
|
+
return self.backend_status(backend) == "verified"
|
|
417
|
+
|
|
418
|
+
def clamp_strength(self, strength: float) -> float:
|
|
419
|
+
"""Clip a live strength into the patch's declared envelope."""
|
|
420
|
+
limit = self.max_abs_strength
|
|
421
|
+
return max(-limit, min(limit, float(strength)))
|
|
422
|
+
|
|
423
|
+
def summary(self) -> str:
|
|
424
|
+
layers = ",".join(str(layer) for layer in self.layers)
|
|
425
|
+
return (
|
|
426
|
+
f"{self.name} [{self.evidence_level}] "
|
|
427
|
+
f"{len(self.interventions)} intervention(s) @ L{layers} "
|
|
428
|
+
f"-> {self.base_model.model_id}"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# -- serialization ---------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
def to_dict(self) -> dict[str, Any]:
|
|
434
|
+
return {
|
|
435
|
+
"format_version": self.format_version,
|
|
436
|
+
"name": self.name,
|
|
437
|
+
"description": self.description,
|
|
438
|
+
"base_model": self.base_model.to_dict(),
|
|
439
|
+
"interventions": [i.to_dict() for i in self.interventions],
|
|
440
|
+
"schedule": self.schedule,
|
|
441
|
+
"evidence_level": self.evidence_level,
|
|
442
|
+
"evaluation": self.evaluation,
|
|
443
|
+
"compatibility": self.compatibility,
|
|
444
|
+
"provenance": self.provenance,
|
|
445
|
+
"max_abs_strength": self.max_abs_strength,
|
|
446
|
+
"default_strength": self.default_strength,
|
|
447
|
+
"license": self.license,
|
|
448
|
+
"authors": list(self.authors),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
452
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
453
|
+
|
|
454
|
+
@classmethod
|
|
455
|
+
def from_dict(cls, data: dict[str, Any]) -> "Manifest":
|
|
456
|
+
if not isinstance(data, dict):
|
|
457
|
+
raise PatchFormatError(f"manifest must be an object, got {type(data).__name__}")
|
|
458
|
+
for key in ("name", "base_model", "interventions"):
|
|
459
|
+
if key not in data:
|
|
460
|
+
raise PatchFormatError(f"manifest is missing required key {key!r}")
|
|
461
|
+
if not isinstance(data["interventions"], list):
|
|
462
|
+
raise PatchFormatError("'interventions' must be a list")
|
|
463
|
+
|
|
464
|
+
manifest = cls(
|
|
465
|
+
name=str(data["name"]),
|
|
466
|
+
base_model=BaseModelSpec.from_dict(data["base_model"]),
|
|
467
|
+
interventions=[Intervention.from_dict(i) for i in data["interventions"]],
|
|
468
|
+
description=str(data.get("description", "")),
|
|
469
|
+
format_version=str(data.get("format_version", FORMAT_VERSION)),
|
|
470
|
+
evidence_level=data.get("evidence_level", "none"),
|
|
471
|
+
evaluation=dict(data.get("evaluation", {})),
|
|
472
|
+
compatibility=dict(data.get("compatibility", {})),
|
|
473
|
+
provenance=dict(data.get("provenance", {})),
|
|
474
|
+
max_abs_strength=float(data.get("max_abs_strength", 8.0)),
|
|
475
|
+
default_strength=float(data.get("default_strength", 1.0)),
|
|
476
|
+
license=str(data.get("license", "Apache-2.0")),
|
|
477
|
+
authors=list(data.get("authors", [])),
|
|
478
|
+
schedule=data.get("schedule"),
|
|
479
|
+
)
|
|
480
|
+
manifest.validate()
|
|
481
|
+
return manifest
|
|
482
|
+
|
|
483
|
+
@classmethod
|
|
484
|
+
def from_json(cls, text: str) -> "Manifest":
|
|
485
|
+
try:
|
|
486
|
+
data = json.loads(text)
|
|
487
|
+
except json.JSONDecodeError as exc:
|
|
488
|
+
raise PatchFormatError(f"manifest is not valid JSON: {exc}") from exc
|
|
489
|
+
return cls.from_dict(data)
|