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