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,260 @@
|
|
|
1
|
+
"""``BrainPatchedModel`` -- the user-facing Python API.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from brainpatch import BrainPatchedModel
|
|
6
|
+
|
|
7
|
+
model = BrainPatchedModel.from_pretrained(
|
|
8
|
+
"Qwen/Qwen2.5-1.5B-Instruct",
|
|
9
|
+
backend="transformers",
|
|
10
|
+
device="auto",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
patch = model.install("09Catho/example-patch")
|
|
14
|
+
patch.strength = 0.8
|
|
15
|
+
|
|
16
|
+
print(model.generate("Evaluate my idea."))
|
|
17
|
+
|
|
18
|
+
This is a thin facade over a :class:`~brainpatch.runtime.base.BrainPatchBackend`.
|
|
19
|
+
It owns no intervention logic of its own -- that lives in the backend contract,
|
|
20
|
+
so every engine behaves identically where it can and reports honestly where it
|
|
21
|
+
cannot.
|
|
22
|
+
|
|
23
|
+
Nothing here knows or cares where a patch was trained.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from typing import Any, Iterator
|
|
30
|
+
|
|
31
|
+
from brainpatch.patch.loader import LoadedPatch, load_patch
|
|
32
|
+
from brainpatch.patch.registry import PatchRegistry, default_registry
|
|
33
|
+
from brainpatch.patch.validation import CompatibilityMode
|
|
34
|
+
from brainpatch.runtime.auto import select_backend
|
|
35
|
+
from brainpatch.runtime.base import ActivePatch, BrainPatchBackend, GenerationConfig
|
|
36
|
+
from brainpatch.runtime.capabilities import Capabilities
|
|
37
|
+
from brainpatch.runtime.scheduling import StrengthSchedule
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PatchHandle:
|
|
41
|
+
"""Live control surface for one installed patch.
|
|
42
|
+
|
|
43
|
+
``patch.strength = 0.8`` and ``patch.schedule = {...}`` are the ergonomic
|
|
44
|
+
forms; both route to the backend so clamping and capability checks still
|
|
45
|
+
apply.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, model: "BrainPatchedModel", name: str) -> None:
|
|
49
|
+
self._model = model
|
|
50
|
+
self._name = name
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
return self._name
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _active(self) -> ActivePatch:
|
|
58
|
+
return self._model.backend.patches[self._name]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def strength(self) -> float:
|
|
62
|
+
return self._active.strength
|
|
63
|
+
|
|
64
|
+
@strength.setter
|
|
65
|
+
def strength(self, value: float) -> None:
|
|
66
|
+
self._model.backend.set_strength(self._name, value)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def enabled(self) -> bool:
|
|
70
|
+
return self._active.enabled
|
|
71
|
+
|
|
72
|
+
@enabled.setter
|
|
73
|
+
def enabled(self, value: bool) -> None:
|
|
74
|
+
self._model.backend.set_enabled(self._name, bool(value))
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def schedule(self) -> StrengthSchedule | None:
|
|
78
|
+
return self._active.schedule
|
|
79
|
+
|
|
80
|
+
@schedule.setter
|
|
81
|
+
def schedule(self, value: StrengthSchedule | dict[int, float] | None) -> None:
|
|
82
|
+
self._model.backend.set_schedule(self._name, value)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def manifest(self) -> Any:
|
|
86
|
+
return self._active.manifest
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def evidence_level(self) -> str:
|
|
90
|
+
return str(self._active.manifest.evidence_level)
|
|
91
|
+
|
|
92
|
+
def describe(self) -> dict[str, Any]:
|
|
93
|
+
return {
|
|
94
|
+
"name": self._name,
|
|
95
|
+
"strength": self.strength,
|
|
96
|
+
"enabled": self.enabled,
|
|
97
|
+
"evidence_level": self.evidence_level,
|
|
98
|
+
"layers": self._active.manifest.layers,
|
|
99
|
+
"scheduled": self.schedule is not None,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
state = "on" if self.enabled else "off"
|
|
104
|
+
return f"<PatchHandle {self._name!r} strength={self.strength:+.3f} [{state}]>"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BrainPatchedModel:
|
|
108
|
+
"""A frozen language model with installable activation patches."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, backend: BrainPatchBackend, registry: PatchRegistry | None = None) -> None:
|
|
111
|
+
self.backend = backend
|
|
112
|
+
self.registry = registry or default_registry()
|
|
113
|
+
self.compatibility_mode: CompatibilityMode = "strict"
|
|
114
|
+
|
|
115
|
+
# -- construction ----------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_pretrained(
|
|
119
|
+
cls,
|
|
120
|
+
model: str,
|
|
121
|
+
*,
|
|
122
|
+
backend: str = "auto",
|
|
123
|
+
device: str = "auto",
|
|
124
|
+
dtype: str = "auto",
|
|
125
|
+
revision: str | None = None,
|
|
126
|
+
registry: PatchRegistry | None = None,
|
|
127
|
+
compatibility_mode: CompatibilityMode = "strict",
|
|
128
|
+
**kwargs: Any,
|
|
129
|
+
) -> "BrainPatchedModel":
|
|
130
|
+
"""Load a frozen base model on the chosen (or best available) backend.
|
|
131
|
+
|
|
132
|
+
``backend="auto"`` picks the first available engine. Naming a backend
|
|
133
|
+
that is unavailable raises rather than substituting a different one.
|
|
134
|
+
"""
|
|
135
|
+
backend_cls = select_backend(backend)
|
|
136
|
+
instance = backend_cls()
|
|
137
|
+
instance.load_model(model, revision=revision, device=device, dtype=dtype, **kwargs)
|
|
138
|
+
patched = cls(instance, registry=registry)
|
|
139
|
+
patched.compatibility_mode = compatibility_mode
|
|
140
|
+
return patched
|
|
141
|
+
|
|
142
|
+
# -- patch management ------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def install(
|
|
145
|
+
self,
|
|
146
|
+
ref: str | os.PathLike[str] | LoadedPatch,
|
|
147
|
+
*,
|
|
148
|
+
strength: float | None = None,
|
|
149
|
+
compatibility_mode: CompatibilityMode | None = None,
|
|
150
|
+
) -> PatchHandle:
|
|
151
|
+
"""Install a patch by installed name, file path, HF reference, or object.
|
|
152
|
+
|
|
153
|
+
Resolution order: an already-installed registry name, then a filesystem
|
|
154
|
+
path, then a Hugging Face ``owner/repo`` reference (which downloads only
|
|
155
|
+
the patch artifact, never the base model).
|
|
156
|
+
"""
|
|
157
|
+
if isinstance(ref, LoadedPatch):
|
|
158
|
+
loaded = ref
|
|
159
|
+
else:
|
|
160
|
+
text = str(ref)
|
|
161
|
+
try:
|
|
162
|
+
loaded = load_patch(self.registry.resolve(text))
|
|
163
|
+
except Exception:
|
|
164
|
+
# Not installed and not a local file: try the Hub, then install.
|
|
165
|
+
installed = self.registry.install(text)
|
|
166
|
+
loaded = installed.load()
|
|
167
|
+
|
|
168
|
+
active = self.backend.install_patch(
|
|
169
|
+
loaded,
|
|
170
|
+
strength=strength,
|
|
171
|
+
mode=compatibility_mode or self.compatibility_mode,
|
|
172
|
+
)
|
|
173
|
+
return PatchHandle(self, active.name)
|
|
174
|
+
|
|
175
|
+
def remove_patch(self, name: str) -> None:
|
|
176
|
+
self.backend.remove_patch(name)
|
|
177
|
+
|
|
178
|
+
def enable_patch(self, name: str) -> None:
|
|
179
|
+
self.backend.set_enabled(name, True)
|
|
180
|
+
|
|
181
|
+
def disable_patch(self, name: str) -> None:
|
|
182
|
+
self.backend.set_enabled(name, False)
|
|
183
|
+
|
|
184
|
+
def set_patch_strength(self, name: str, strength: float) -> float:
|
|
185
|
+
return self.backend.set_strength(name, strength)
|
|
186
|
+
|
|
187
|
+
def set_patch_schedule(
|
|
188
|
+
self, name: str, schedule: StrengthSchedule | dict[int, float] | None
|
|
189
|
+
) -> None:
|
|
190
|
+
self.backend.set_schedule(name, schedule)
|
|
191
|
+
|
|
192
|
+
def list_patches(self) -> list[str]:
|
|
193
|
+
return self.backend.list_patches()
|
|
194
|
+
|
|
195
|
+
def patch(self, name: str) -> PatchHandle:
|
|
196
|
+
if name not in self.backend.patches:
|
|
197
|
+
raise KeyError(f"no patch named {name!r} is installed")
|
|
198
|
+
return PatchHandle(self, name)
|
|
199
|
+
|
|
200
|
+
# -- generation ------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def generate(self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any) -> str:
|
|
203
|
+
return self.backend.generate(prompt, config, **kwargs)
|
|
204
|
+
|
|
205
|
+
def stream(
|
|
206
|
+
self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
|
|
207
|
+
) -> Iterator[str]:
|
|
208
|
+
return self.backend.stream(prompt, config, **kwargs)
|
|
209
|
+
|
|
210
|
+
def compare(
|
|
211
|
+
self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
|
|
212
|
+
) -> dict[str, str]:
|
|
213
|
+
"""Generate the same prompt with patches off and on.
|
|
214
|
+
|
|
215
|
+
Disables patches rather than uninstalling them, so strengths and
|
|
216
|
+
schedules survive the comparison.
|
|
217
|
+
"""
|
|
218
|
+
states = {name: p.enabled for name, p in self.backend.patches.items()}
|
|
219
|
+
try:
|
|
220
|
+
for name in states:
|
|
221
|
+
self.backend.set_enabled(name, False)
|
|
222
|
+
baseline = self.generate(prompt, config, **kwargs)
|
|
223
|
+
for name in states:
|
|
224
|
+
self.backend.set_enabled(name, True)
|
|
225
|
+
patched = self.generate(prompt, config, **kwargs)
|
|
226
|
+
finally:
|
|
227
|
+
for name, was_enabled in states.items():
|
|
228
|
+
self.backend.set_enabled(name, was_enabled)
|
|
229
|
+
return {"baseline": baseline, "patched": patched}
|
|
230
|
+
|
|
231
|
+
# -- introspection ---------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def capabilities(self) -> Capabilities:
|
|
234
|
+
return type(self.backend).capabilities()
|
|
235
|
+
|
|
236
|
+
def describe(self) -> dict[str, Any]:
|
|
237
|
+
descriptor = self.backend.describe_model()
|
|
238
|
+
return {
|
|
239
|
+
"backend": self.backend.name,
|
|
240
|
+
"model": descriptor.model_id,
|
|
241
|
+
"architecture": descriptor.architecture,
|
|
242
|
+
"hidden_size": descriptor.hidden_size,
|
|
243
|
+
"num_layers": descriptor.num_layers,
|
|
244
|
+
"revision": descriptor.revision,
|
|
245
|
+
"compatibility_mode": self.compatibility_mode,
|
|
246
|
+
"patches": [PatchHandle(self, n).describe() for n in self.backend.patches],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def unload(self) -> None:
|
|
250
|
+
self.backend.unload()
|
|
251
|
+
|
|
252
|
+
def __enter__(self) -> "BrainPatchedModel":
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def __exit__(self, *exc: Any) -> None:
|
|
256
|
+
self.unload()
|
|
257
|
+
|
|
258
|
+
def __repr__(self) -> str:
|
|
259
|
+
patches = ", ".join(self.backend.patches) or "none"
|
|
260
|
+
return f"<BrainPatchedModel backend={self.backend.name!r} patches=[{patches}]>"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Token-indexed strength schedules.
|
|
2
|
+
|
|
3
|
+
Re-exported from :mod:`brainpatch.steering.schedule`, which already implements
|
|
4
|
+
and tests this. It lives under ``runtime`` too because scheduling is a *runtime*
|
|
5
|
+
concern, not a research one, and a user reading the runtime package should not
|
|
6
|
+
have to know that the code happens to predate the split.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from brainpatch.steering.schedule import StrengthSchedule
|
|
12
|
+
|
|
13
|
+
__all__ = ["StrengthSchedule"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Serializable schemas shared between the local control plane and Modal.
|
|
2
|
+
|
|
3
|
+
Every schema here is a plain :mod:`dataclasses` structure with explicit
|
|
4
|
+
``to_dict`` / ``from_dict`` methods. No pydantic, no torch: these types travel
|
|
5
|
+
between a laptop with no ML stack and a GPU container, so they must be
|
|
6
|
+
constructible from nothing but the standard library.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from brainpatch.schemas.contrast import ContrastExample, ContrastSet
|
|
10
|
+
from brainpatch.schemas.feature import FeatureContext, FeatureRecord, FeatureStats
|
|
11
|
+
from brainpatch.schemas.manifest import ActivationManifest, ShardRecord
|
|
12
|
+
from brainpatch.schemas.patch import (
|
|
13
|
+
BrainPatchSpec,
|
|
14
|
+
FeatureEdit,
|
|
15
|
+
PatchCompatibilityError,
|
|
16
|
+
PatchValidationError,
|
|
17
|
+
SAEReference,
|
|
18
|
+
)
|
|
19
|
+
from brainpatch.schemas.sae import SAEConfig
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ActivationManifest",
|
|
23
|
+
"BrainPatchSpec",
|
|
24
|
+
"ContrastExample",
|
|
25
|
+
"ContrastSet",
|
|
26
|
+
"FeatureContext",
|
|
27
|
+
"FeatureEdit",
|
|
28
|
+
"FeatureRecord",
|
|
29
|
+
"FeatureStats",
|
|
30
|
+
"PatchCompatibilityError",
|
|
31
|
+
"PatchValidationError",
|
|
32
|
+
"SAEConfig",
|
|
33
|
+
"SAEReference",
|
|
34
|
+
"ShardRecord",
|
|
35
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Behavioural contrast datasets.
|
|
2
|
+
|
|
3
|
+
A contrast example pairs one prompt with a *positive* response (the behaviour we
|
|
4
|
+
want more of) and a *negative* response (the behaviour we want less of). The
|
|
5
|
+
difference in internal activations between the two is the starting point for
|
|
6
|
+
candidate-feature search.
|
|
7
|
+
|
|
8
|
+
These are **development fixtures**, not benchmarks. The sets shipped in
|
|
9
|
+
``examples/contrast/`` are small, hand-written, synthetic, and were never
|
|
10
|
+
validated against human judgement or an external standard. They exist to
|
|
11
|
+
exercise the pipeline; any number computed on them is a smoke-test number.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from typing import Any, Iterable, Iterator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ContrastExample:
|
|
23
|
+
"""One prompt with a contrasting pair of responses."""
|
|
24
|
+
|
|
25
|
+
prompt: str
|
|
26
|
+
positive_response: str
|
|
27
|
+
negative_response: str
|
|
28
|
+
category: str = "uncategorized"
|
|
29
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def validate(self) -> None:
|
|
32
|
+
if not self.prompt.strip():
|
|
33
|
+
raise ValueError("contrast example has an empty prompt")
|
|
34
|
+
if not self.positive_response.strip():
|
|
35
|
+
raise ValueError(f"empty positive_response for prompt {self.prompt[:60]!r}")
|
|
36
|
+
if not self.negative_response.strip():
|
|
37
|
+
raise ValueError(f"empty negative_response for prompt {self.prompt[:60]!r}")
|
|
38
|
+
if self.positive_response.strip() == self.negative_response.strip():
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"positive and negative responses are identical for prompt {self.prompt[:60]!r}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
return asdict(self)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict[str, Any]) -> "ContrastExample":
|
|
48
|
+
missing = {"prompt", "positive_response", "negative_response"} - set(data)
|
|
49
|
+
if missing:
|
|
50
|
+
raise ValueError(f"contrast example is missing keys: {sorted(missing)}")
|
|
51
|
+
return cls(
|
|
52
|
+
prompt=str(data["prompt"]),
|
|
53
|
+
positive_response=str(data["positive_response"]),
|
|
54
|
+
negative_response=str(data["negative_response"]),
|
|
55
|
+
category=str(data.get("category", "uncategorized")),
|
|
56
|
+
metadata=dict(data.get("metadata", {})),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ContrastSet:
|
|
62
|
+
"""A named collection of contrast examples.
|
|
63
|
+
|
|
64
|
+
Attributes
|
|
65
|
+
----------
|
|
66
|
+
synthetic:
|
|
67
|
+
True for hand-written development fixtures. Always keep this honest --
|
|
68
|
+
it is what stops a fixture from being reported as a benchmark.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
description: str
|
|
73
|
+
examples: list[ContrastExample] = field(default_factory=list)
|
|
74
|
+
synthetic: bool = True
|
|
75
|
+
version: str = "0.1"
|
|
76
|
+
|
|
77
|
+
def __len__(self) -> int:
|
|
78
|
+
return len(self.examples)
|
|
79
|
+
|
|
80
|
+
def __iter__(self) -> Iterator[ContrastExample]:
|
|
81
|
+
return iter(self.examples)
|
|
82
|
+
|
|
83
|
+
def validate(self) -> None:
|
|
84
|
+
if not self.name:
|
|
85
|
+
raise ValueError("contrast set must have a name")
|
|
86
|
+
if not self.examples:
|
|
87
|
+
raise ValueError(f"contrast set {self.name!r} is empty")
|
|
88
|
+
for example in self.examples:
|
|
89
|
+
example.validate()
|
|
90
|
+
|
|
91
|
+
def categories(self) -> list[str]:
|
|
92
|
+
"""Distinct categories present, in sorted order."""
|
|
93
|
+
return sorted({e.category for e in self.examples})
|
|
94
|
+
|
|
95
|
+
def filter(self, category: str) -> "ContrastSet":
|
|
96
|
+
"""A new set containing only examples from ``category``."""
|
|
97
|
+
return ContrastSet(
|
|
98
|
+
name=f"{self.name}:{category}",
|
|
99
|
+
description=self.description,
|
|
100
|
+
examples=[e for e in self.examples if e.category == category],
|
|
101
|
+
synthetic=self.synthetic,
|
|
102
|
+
version=self.version,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def split(self, holdout_fraction: float, seed: int = 0) -> tuple["ContrastSet", "ContrastSet"]:
|
|
106
|
+
"""Deterministically split into (train, holdout).
|
|
107
|
+
|
|
108
|
+
Uses a seeded :class:`random.Random` so that patch search and held-out
|
|
109
|
+
evaluation never accidentally see the same examples across runs.
|
|
110
|
+
"""
|
|
111
|
+
import random
|
|
112
|
+
|
|
113
|
+
if not 0.0 < holdout_fraction < 1.0:
|
|
114
|
+
raise ValueError(f"holdout_fraction must be in (0, 1), got {holdout_fraction}")
|
|
115
|
+
indices = list(range(len(self.examples)))
|
|
116
|
+
random.Random(seed).shuffle(indices)
|
|
117
|
+
n_holdout = max(1, int(round(len(indices) * holdout_fraction)))
|
|
118
|
+
holdout_idx = set(indices[:n_holdout])
|
|
119
|
+
|
|
120
|
+
train = [e for i, e in enumerate(self.examples) if i not in holdout_idx]
|
|
121
|
+
holdout = [e for i, e in enumerate(self.examples) if i in holdout_idx]
|
|
122
|
+
return (
|
|
123
|
+
ContrastSet(f"{self.name}:train", self.description, train, self.synthetic, self.version),
|
|
124
|
+
ContrastSet(
|
|
125
|
+
f"{self.name}:holdout", self.description, holdout, self.synthetic, self.version
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, Any]:
|
|
130
|
+
return {
|
|
131
|
+
"name": self.name,
|
|
132
|
+
"description": self.description,
|
|
133
|
+
"synthetic": self.synthetic,
|
|
134
|
+
"version": self.version,
|
|
135
|
+
"examples": [e.to_dict() for e in self.examples],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
139
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, data: dict[str, Any]) -> "ContrastSet":
|
|
143
|
+
if "name" not in data:
|
|
144
|
+
raise ValueError("contrast set is missing 'name'")
|
|
145
|
+
return cls(
|
|
146
|
+
name=str(data["name"]),
|
|
147
|
+
description=str(data.get("description", "")),
|
|
148
|
+
examples=[ContrastExample.from_dict(e) for e in data.get("examples", [])],
|
|
149
|
+
synthetic=bool(data.get("synthetic", True)),
|
|
150
|
+
version=str(data.get("version", "0.1")),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_json(cls, text: str) -> "ContrastSet":
|
|
155
|
+
return cls.from_dict(json.loads(text))
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_examples(
|
|
159
|
+
cls, name: str, description: str, examples: Iterable[ContrastExample]
|
|
160
|
+
) -> "ContrastSet":
|
|
161
|
+
return cls(name=name, description=description, examples=list(examples))
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Feature-database records.
|
|
2
|
+
|
|
3
|
+
A :class:`FeatureRecord` describes one SAE feature: how often it fires, how
|
|
4
|
+
strongly, and which token contexts drive it hardest.
|
|
5
|
+
|
|
6
|
+
Scientific note
|
|
7
|
+
---------------
|
|
8
|
+
``FeatureRecord.hypothesis`` is exactly that -- a *hypothesis*. Top activating
|
|
9
|
+
examples are correlational evidence and nothing more. The field
|
|
10
|
+
:attr:`FeatureRecord.evidence_level` records how much support a semantic
|
|
11
|
+
description actually has, and it never advances past ``"correlational"``
|
|
12
|
+
automatically. Only an intervention experiment with scale-matched controls can
|
|
13
|
+
move a feature to ``"controlled_interventional"``, and only an independent
|
|
14
|
+
repetition can move it to ``"replicated"``. Both transitions are performed by
|
|
15
|
+
the validation pipeline writing a measured result, never by a labelling
|
|
16
|
+
heuristic.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
#: Ordered strength of evidence behind a semantic claim about a feature.
|
|
26
|
+
#:
|
|
27
|
+
#: Deliberately conservative. An earlier version of this ladder topped out at
|
|
28
|
+
#: ``causal``, which claimed more than a single controlled experiment can
|
|
29
|
+
#: deliver: passing scale-matched controls once, on one model, at one layer,
|
|
30
|
+
#: with one prompt set, is evidence *consistent with* a causal effect -- not a
|
|
31
|
+
#: demonstration of causation. The top two rungs now name what was actually
|
|
32
|
+
#: done (controls were run; the result was reproduced) rather than what one
|
|
33
|
+
#: might wish to infer from it.
|
|
34
|
+
EvidenceLevel = Literal[
|
|
35
|
+
# no description offered
|
|
36
|
+
"none",
|
|
37
|
+
# top-activating contexts look suggestive; nothing more
|
|
38
|
+
"correlational",
|
|
39
|
+
# feature activation predicts a behaviour on held-out data
|
|
40
|
+
"predictive",
|
|
41
|
+
# steering it changes behaviour; controls absent, incomplete, or not yet run
|
|
42
|
+
"interventional",
|
|
43
|
+
# steering changes behaviour AND scale-matched controls did not, in one
|
|
44
|
+
# experiment with adequate statistical power
|
|
45
|
+
"controlled_interventional",
|
|
46
|
+
# the controlled result held up on independent repetition -- a different
|
|
47
|
+
# prompt set, seed, or run
|
|
48
|
+
"replicated",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
EVIDENCE_ORDER: tuple[str, ...] = (
|
|
52
|
+
"none",
|
|
53
|
+
"correlational",
|
|
54
|
+
"predictive",
|
|
55
|
+
"interventional",
|
|
56
|
+
"controlled_interventional",
|
|
57
|
+
"replicated",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
#: Levels at which scale-matched controls have actually been run and passed.
|
|
61
|
+
CONTROLLED_LEVELS: frozenset[str] = frozenset({"controlled_interventional", "replicated"})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FeatureContext:
|
|
66
|
+
"""One high-activation occurrence of a feature, with surrounding text."""
|
|
67
|
+
|
|
68
|
+
example_index: int
|
|
69
|
+
token_position: int
|
|
70
|
+
token_id: int
|
|
71
|
+
token_text: str
|
|
72
|
+
activation: float
|
|
73
|
+
#: Decoded text a few tokens either side, for human inspection.
|
|
74
|
+
context_before: str = ""
|
|
75
|
+
context_after: str = ""
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: dict[str, Any]) -> "FeatureContext":
|
|
82
|
+
known = {f for f in cls.__dataclass_fields__} # noqa: SLF001
|
|
83
|
+
return cls(**{k: v for k, v in data.items() if k in known}) # type: ignore[arg-type]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class FeatureStats:
|
|
88
|
+
"""Activation statistics for one feature over an activation corpus."""
|
|
89
|
+
|
|
90
|
+
fire_count: int = 0
|
|
91
|
+
total_tokens: int = 0
|
|
92
|
+
mean_activation: float = 0.0
|
|
93
|
+
"""Mean over *firing* tokens only (zeros excluded)."""
|
|
94
|
+
max_activation: float = 0.0
|
|
95
|
+
std_activation: float = 0.0
|
|
96
|
+
decoder_norm: float = 0.0
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def firing_rate(self) -> float:
|
|
100
|
+
"""Fraction of tokens on which this feature is active."""
|
|
101
|
+
if self.total_tokens == 0:
|
|
102
|
+
return 0.0
|
|
103
|
+
return self.fire_count / self.total_tokens
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_dead(self) -> bool:
|
|
107
|
+
"""A feature that never fired over the analysed corpus."""
|
|
108
|
+
return self.fire_count == 0
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, Any]:
|
|
111
|
+
data = asdict(self)
|
|
112
|
+
data["firing_rate"] = self.firing_rate
|
|
113
|
+
data["is_dead"] = self.is_dead
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, data: dict[str, Any]) -> "FeatureStats":
|
|
118
|
+
known = {f for f in cls.__dataclass_fields__} # noqa: SLF001
|
|
119
|
+
return cls(**{k: v for k, v in data.items() if k in known}) # type: ignore[arg-type]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class FeatureRecord:
|
|
124
|
+
"""A single entry in the feature database."""
|
|
125
|
+
|
|
126
|
+
feature_id: int
|
|
127
|
+
stats: FeatureStats = field(default_factory=FeatureStats)
|
|
128
|
+
top_contexts: list[FeatureContext] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
#: Tentative, human- or machine-suggested description. NOT a validated label.
|
|
131
|
+
hypothesis: str | None = None
|
|
132
|
+
evidence_level: EvidenceLevel = "none"
|
|
133
|
+
#: Free-form pointers to experiments that produced the evidence.
|
|
134
|
+
evidence_refs: list[str] = field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
if self.evidence_level not in EVIDENCE_ORDER:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"unknown evidence_level {self.evidence_level!r}; "
|
|
140
|
+
f"expected one of {EVIDENCE_ORDER}"
|
|
141
|
+
)
|
|
142
|
+
if self.hypothesis is not None and self.evidence_level == "none":
|
|
143
|
+
# A description always carries at least correlational weight; being
|
|
144
|
+
# explicit here prevents an unlabelled-looking record from silently
|
|
145
|
+
# shipping a semantic claim.
|
|
146
|
+
self.evidence_level = "correlational"
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def has_controlled_evidence(self) -> bool:
|
|
150
|
+
"""True once scale-matched controls have been run and passed."""
|
|
151
|
+
return self.evidence_level in CONTROLLED_LEVELS
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_validated(self) -> bool:
|
|
155
|
+
"""True only for a controlled result that survived independent repetition.
|
|
156
|
+
|
|
157
|
+
Intentionally strict: one passing experiment is
|
|
158
|
+
``controlled_interventional``, not validation.
|
|
159
|
+
"""
|
|
160
|
+
return self.evidence_level == "replicated"
|
|
161
|
+
|
|
162
|
+
def label_for_display(self) -> str:
|
|
163
|
+
"""Human-facing label that never overstates the evidence."""
|
|
164
|
+
if self.hypothesis is None:
|
|
165
|
+
return f"feature {self.feature_id} (no description)"
|
|
166
|
+
if self.is_validated:
|
|
167
|
+
return f"feature {self.feature_id}: {self.hypothesis} [replicated]"
|
|
168
|
+
return f"feature {self.feature_id}: {self.hypothesis} [{self.evidence_level}, unvalidated]"
|
|
169
|
+
|
|
170
|
+
def to_dict(self) -> dict[str, Any]:
|
|
171
|
+
return {
|
|
172
|
+
"feature_id": self.feature_id,
|
|
173
|
+
"stats": self.stats.to_dict(),
|
|
174
|
+
"top_contexts": [c.to_dict() for c in self.top_contexts],
|
|
175
|
+
"hypothesis": self.hypothesis,
|
|
176
|
+
"evidence_level": self.evidence_level,
|
|
177
|
+
"evidence_refs": list(self.evidence_refs),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def to_json(self) -> str:
|
|
181
|
+
"""Single-line JSON, suitable for a ``features.jsonl`` row."""
|
|
182
|
+
return json.dumps(self.to_dict(), sort_keys=True)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_dict(cls, data: dict[str, Any]) -> "FeatureRecord":
|
|
186
|
+
return cls(
|
|
187
|
+
feature_id=int(data["feature_id"]),
|
|
188
|
+
stats=FeatureStats.from_dict(data.get("stats", {})),
|
|
189
|
+
top_contexts=[FeatureContext.from_dict(c) for c in data.get("top_contexts", [])],
|
|
190
|
+
hypothesis=data.get("hypothesis"),
|
|
191
|
+
evidence_level=data.get("evidence_level", "none"),
|
|
192
|
+
evidence_refs=list(data.get("evidence_refs", [])),
|
|
193
|
+
)
|