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