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