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
brainpatch/config.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""YAML experiment configuration with dotted CLI overrides.
|
|
2
|
+
|
|
3
|
+
Every BrainPatch experiment is fully described by a YAML file plus a list of
|
|
4
|
+
``key.path=value`` overrides. The resolved dictionary is written into the
|
|
5
|
+
experiment directory on the Volume, so a run can always be reconstructed from
|
|
6
|
+
its own artifacts.
|
|
7
|
+
|
|
8
|
+
The loader is intentionally boring: no interpolation, no plugins, no imports.
|
|
9
|
+
It has to run on a machine with nothing but ``pyyaml`` installed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Iterable, Mapping
|
|
20
|
+
|
|
21
|
+
try: # pragma: no cover - exercised implicitly; guard keeps import failures readable
|
|
22
|
+
import yaml
|
|
23
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
24
|
+
raise ModuleNotFoundError(
|
|
25
|
+
"brainpatch.config requires PyYAML. Install the tiny control-plane extra: "
|
|
26
|
+
"pip install 'brainpatch[modal]'"
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigError(ValueError):
|
|
31
|
+
"""Raised when a configuration file or override is unusable."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# loading
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_yaml(path: str | os.PathLike[str]) -> dict[str, Any]:
|
|
40
|
+
"""Load a YAML file into a plain dict.
|
|
41
|
+
|
|
42
|
+
Raises
|
|
43
|
+
------
|
|
44
|
+
ConfigError
|
|
45
|
+
If the file is missing, unparseable, or does not contain a mapping.
|
|
46
|
+
"""
|
|
47
|
+
p = Path(path)
|
|
48
|
+
if not p.is_file():
|
|
49
|
+
raise ConfigError(f"config file not found: {p}")
|
|
50
|
+
try:
|
|
51
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
52
|
+
except yaml.YAMLError as exc:
|
|
53
|
+
raise ConfigError(f"could not parse {p}: {exc}") from exc
|
|
54
|
+
if data is None:
|
|
55
|
+
return {}
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
raise ConfigError(f"{p} must contain a YAML mapping at the top level, got {type(data).__name__}")
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
62
|
+
"""Recursively merge ``override`` into ``base``, returning a new dict.
|
|
63
|
+
|
|
64
|
+
Nested mappings merge key-by-key; every other type is replaced wholesale.
|
|
65
|
+
|
|
66
|
+
>>> deep_merge({"a": {"x": 1, "y": 2}}, {"a": {"y": 3}})
|
|
67
|
+
{'a': {'x': 1, 'y': 3}}
|
|
68
|
+
"""
|
|
69
|
+
result: dict[str, Any] = dict(base)
|
|
70
|
+
for key, value in override.items():
|
|
71
|
+
existing = result.get(key)
|
|
72
|
+
if isinstance(existing, Mapping) and isinstance(value, Mapping):
|
|
73
|
+
result[key] = deep_merge(existing, value)
|
|
74
|
+
else:
|
|
75
|
+
result[key] = value
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _coerce_scalar(raw: str, value: Any) -> Any:
|
|
80
|
+
"""Recover numeric types that YAML 1.1 leaves as strings.
|
|
81
|
+
|
|
82
|
+
``yaml.safe_load`` follows the YAML 1.1 float grammar, which requires a
|
|
83
|
+
decimal point and a signed exponent: ``1.0e-4`` parses as a float but
|
|
84
|
+
``1e-4`` parses as the *string* ``"1e-4"``. That is a nasty failure mode for
|
|
85
|
+
a config override -- ``--set sae.lr=1e-4`` would silently hand a string to
|
|
86
|
+
the optimizer instead of a learning rate. Python's own float grammar is more
|
|
87
|
+
permissive, so retry with it.
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(value, str):
|
|
90
|
+
return value
|
|
91
|
+
text = value.strip()
|
|
92
|
+
if not text:
|
|
93
|
+
return value
|
|
94
|
+
try:
|
|
95
|
+
return int(text)
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
try:
|
|
99
|
+
return float(text)
|
|
100
|
+
except ValueError:
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_override(text: str) -> tuple[list[str], Any]:
|
|
105
|
+
"""Parse a ``a.b.c=value`` override into a key path and a typed value.
|
|
106
|
+
|
|
107
|
+
Values are parsed as YAML scalars with a numeric-coercion fallback, so
|
|
108
|
+
``k=32`` gives an int, ``lr=1e-4`` a float, ``flag=true`` a bool, and
|
|
109
|
+
``name=smoke_v0`` a string.
|
|
110
|
+
|
|
111
|
+
>>> parse_override("sae.k=32")
|
|
112
|
+
(['sae', 'k'], 32)
|
|
113
|
+
>>> parse_override("run.force=true")
|
|
114
|
+
(['run', 'force'], True)
|
|
115
|
+
>>> parse_override("sae.lr=1e-4")
|
|
116
|
+
(['sae', 'lr'], 0.0001)
|
|
117
|
+
"""
|
|
118
|
+
if "=" not in text:
|
|
119
|
+
raise ConfigError(f"override {text!r} must look like 'key.path=value'")
|
|
120
|
+
key, _, raw = text.partition("=")
|
|
121
|
+
key = key.strip()
|
|
122
|
+
if not key:
|
|
123
|
+
raise ConfigError(f"override {text!r} has an empty key")
|
|
124
|
+
try:
|
|
125
|
+
value = yaml.safe_load(raw)
|
|
126
|
+
except yaml.YAMLError as exc:
|
|
127
|
+
raise ConfigError(f"could not parse override value in {text!r}: {exc}") from exc
|
|
128
|
+
return key.split("."), _coerce_scalar(raw, value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def apply_overrides(config: Mapping[str, Any], overrides: Iterable[str]) -> dict[str, Any]:
|
|
132
|
+
"""Apply dotted ``key=value`` overrides on top of ``config``."""
|
|
133
|
+
result = json.loads(json.dumps(config)) if config else {}
|
|
134
|
+
for override in overrides:
|
|
135
|
+
path, value = parse_override(override)
|
|
136
|
+
cursor = result
|
|
137
|
+
for part in path[:-1]:
|
|
138
|
+
nxt = cursor.get(part)
|
|
139
|
+
if not isinstance(nxt, dict):
|
|
140
|
+
nxt = {}
|
|
141
|
+
cursor[part] = nxt
|
|
142
|
+
cursor = nxt
|
|
143
|
+
cursor[path[-1]] = value
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_config(
|
|
148
|
+
path: str | os.PathLike[str] | None,
|
|
149
|
+
overrides: Iterable[str] = (),
|
|
150
|
+
*,
|
|
151
|
+
defaults: Mapping[str, Any] | None = None,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
"""Resolve defaults -> YAML file -> CLI overrides into one dict."""
|
|
154
|
+
config: dict[str, Any] = dict(defaults or {})
|
|
155
|
+
if path is not None:
|
|
156
|
+
config = deep_merge(config, load_yaml(path))
|
|
157
|
+
return apply_overrides(config, overrides)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def require(config: Mapping[str, Any], dotted_key: str) -> Any:
|
|
161
|
+
"""Fetch ``a.b.c`` from a nested config, with a clear error if absent."""
|
|
162
|
+
cursor: Any = config
|
|
163
|
+
for part in dotted_key.split("."):
|
|
164
|
+
if not isinstance(cursor, Mapping) or part not in cursor:
|
|
165
|
+
raise ConfigError(f"missing required config key {dotted_key!r}")
|
|
166
|
+
cursor = cursor[part]
|
|
167
|
+
return cursor
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get(config: Mapping[str, Any], dotted_key: str, default: Any = None) -> Any:
|
|
171
|
+
"""Fetch ``a.b.c`` from a nested config, returning ``default`` if absent."""
|
|
172
|
+
try:
|
|
173
|
+
return require(config, dotted_key)
|
|
174
|
+
except ConfigError:
|
|
175
|
+
return default
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# provenance
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class RunProvenance:
|
|
185
|
+
"""Everything needed to say *what exactly* produced an artifact."""
|
|
186
|
+
|
|
187
|
+
git_commit: str | None = None
|
|
188
|
+
git_dirty: bool | None = None
|
|
189
|
+
package_versions: dict[str, str] = field(default_factory=dict)
|
|
190
|
+
gpu: str | None = None
|
|
191
|
+
hostname: str | None = None
|
|
192
|
+
started_at: str | None = None
|
|
193
|
+
finished_at: str | None = None
|
|
194
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
195
|
+
|
|
196
|
+
def to_dict(self) -> dict[str, Any]:
|
|
197
|
+
return {
|
|
198
|
+
"git_commit": self.git_commit,
|
|
199
|
+
"git_dirty": self.git_dirty,
|
|
200
|
+
"package_versions": dict(self.package_versions),
|
|
201
|
+
"gpu": self.gpu,
|
|
202
|
+
"hostname": self.hostname,
|
|
203
|
+
"started_at": self.started_at,
|
|
204
|
+
"finished_at": self.finished_at,
|
|
205
|
+
**self.extra,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def git_commit(repo: str | os.PathLike[str] | None = None) -> str | None:
|
|
210
|
+
"""Return the current git commit SHA, or ``None`` outside a repository.
|
|
211
|
+
|
|
212
|
+
Never raises: provenance capture must not be able to fail a run.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
out = subprocess.run(
|
|
216
|
+
["git", "rev-parse", "HEAD"],
|
|
217
|
+
cwd=str(repo) if repo else None,
|
|
218
|
+
capture_output=True,
|
|
219
|
+
text=True,
|
|
220
|
+
timeout=10,
|
|
221
|
+
check=False,
|
|
222
|
+
)
|
|
223
|
+
except (OSError, subprocess.SubprocessError):
|
|
224
|
+
return None
|
|
225
|
+
if out.returncode != 0:
|
|
226
|
+
return None
|
|
227
|
+
return out.stdout.strip() or None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def git_is_dirty(repo: str | os.PathLike[str] | None = None) -> bool | None:
|
|
231
|
+
"""True if the working tree has uncommitted changes; ``None`` if unknown."""
|
|
232
|
+
try:
|
|
233
|
+
out = subprocess.run(
|
|
234
|
+
["git", "status", "--porcelain"],
|
|
235
|
+
cwd=str(repo) if repo else None,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
text=True,
|
|
238
|
+
timeout=10,
|
|
239
|
+
check=False,
|
|
240
|
+
)
|
|
241
|
+
except (OSError, subprocess.SubprocessError):
|
|
242
|
+
return None
|
|
243
|
+
if out.returncode != 0:
|
|
244
|
+
return None
|
|
245
|
+
return bool(out.stdout.strip())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Dataset helpers.
|
|
2
|
+
|
|
3
|
+
Contrast-set loading is pure Python and lives here. Text-corpus ingestion for
|
|
4
|
+
activation extraction needs ``datasets`` and lives in
|
|
5
|
+
:mod:`brainpatch.research.ml.corpus`, which is only imported inside Modal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from brainpatch.datasets.contrast_sets import (
|
|
9
|
+
CONTRAST_SET_NAMES,
|
|
10
|
+
default_contrast_dir,
|
|
11
|
+
list_contrast_sets,
|
|
12
|
+
load_contrast_set,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CONTRAST_SET_NAMES",
|
|
17
|
+
"default_contrast_dir",
|
|
18
|
+
"list_contrast_sets",
|
|
19
|
+
"load_contrast_set",
|
|
20
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Loading behavioural contrast fixtures.
|
|
2
|
+
|
|
3
|
+
The sets shipped in ``examples/contrast/`` are small, synthetic, hand-written
|
|
4
|
+
development fixtures. They are the input to candidate-feature search, and they
|
|
5
|
+
are *not* benchmarks -- see the module docstring of
|
|
6
|
+
:mod:`brainpatch.schemas.contrast`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from brainpatch.schemas.contrast import ContrastSet
|
|
15
|
+
|
|
16
|
+
#: Fixtures shipped with the repository.
|
|
17
|
+
CONTRAST_SET_NAMES: tuple[str, ...] = (
|
|
18
|
+
"sycophancy",
|
|
19
|
+
"verification",
|
|
20
|
+
"verbosity",
|
|
21
|
+
"contradiction",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_contrast_dir() -> Path:
|
|
26
|
+
"""Directory holding the shipped contrast fixtures.
|
|
27
|
+
|
|
28
|
+
Resolved relative to the installed package so it works from a checkout and
|
|
29
|
+
from inside a Modal container where the repo lives at ``/root``.
|
|
30
|
+
"""
|
|
31
|
+
here = Path(__file__).resolve()
|
|
32
|
+
# brainpatch/datasets/contrast_sets.py -> repo root
|
|
33
|
+
repo_root = here.parent.parent.parent
|
|
34
|
+
return repo_root / "examples" / "contrast"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def list_contrast_sets(directory: str | os.PathLike[str] | None = None) -> list[str]:
|
|
38
|
+
"""Names of every contrast set found in ``directory``."""
|
|
39
|
+
d = Path(directory) if directory is not None else default_contrast_dir()
|
|
40
|
+
if not d.is_dir():
|
|
41
|
+
return []
|
|
42
|
+
return sorted(p.stem for p in d.glob("*.json"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_contrast_set(
|
|
46
|
+
name: str, directory: str | os.PathLike[str] | None = None
|
|
47
|
+
) -> ContrastSet:
|
|
48
|
+
"""Load a contrast set by name, validating its contents.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
FileNotFoundError
|
|
53
|
+
If no such set exists, listing what is available.
|
|
54
|
+
"""
|
|
55
|
+
d = Path(directory) if directory is not None else default_contrast_dir()
|
|
56
|
+
path = d / f"{name}.json"
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
available = list_contrast_sets(d)
|
|
59
|
+
raise FileNotFoundError(
|
|
60
|
+
f"contrast set {name!r} not found at {path}. Available: {available or 'none'}"
|
|
61
|
+
)
|
|
62
|
+
contrast_set = ContrastSet.from_json(path.read_text(encoding="utf-8"))
|
|
63
|
+
contrast_set.validate()
|
|
64
|
+
return contrast_set
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Evaluation utilities.
|
|
2
|
+
|
|
3
|
+
Pure-Python text metrics live in :mod:`brainpatch.evaluation.metrics` and can be
|
|
4
|
+
computed anywhere. Model-dependent measurements (log-probabilities, capability
|
|
5
|
+
probes) require torch and live in :mod:`brainpatch.research.ml.evaluation`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from brainpatch.evaluation.metrics import (
|
|
9
|
+
GenerationMetrics,
|
|
10
|
+
compare_generations,
|
|
11
|
+
distinct_n,
|
|
12
|
+
jaccard_similarity,
|
|
13
|
+
longest_repeated_ngram,
|
|
14
|
+
most_common_ngram_fraction,
|
|
15
|
+
repetition_rate,
|
|
16
|
+
score_generation,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"GenerationMetrics",
|
|
21
|
+
"compare_generations",
|
|
22
|
+
"distinct_n",
|
|
23
|
+
"jaccard_similarity",
|
|
24
|
+
"longest_repeated_ngram",
|
|
25
|
+
"most_common_ngram_fraction",
|
|
26
|
+
"repetition_rate",
|
|
27
|
+
"score_generation",
|
|
28
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Model-free text metrics for detecting intervention side effects.
|
|
2
|
+
|
|
3
|
+
A large enough residual-stream perturbation degrades a model's fluency, and it
|
|
4
|
+
can do so before it changes the behaviour you were aiming at -- observed here at
|
|
5
|
+
steering strength 32, where the model looped. These metrics are the cheap
|
|
6
|
+
tripwires: they catch degeneration (loops, single-token spam, truncation)
|
|
7
|
+
without needing a judge model or a paid API.
|
|
8
|
+
|
|
9
|
+
They are *not* measures of quality. A high `distinct_2` does not mean the answer
|
|
10
|
+
is good; a low one strongly suggests the answer is broken. Use them to reject
|
|
11
|
+
interventions, not to award them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from typing import Any, Sequence
|
|
20
|
+
|
|
21
|
+
_WORD_RE = re.compile(r"\w+(?:'\w+)?|[^\w\s]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tokenize_words(text: str) -> list[str]:
|
|
25
|
+
"""Cheap whitespace/punctuation tokenizer, lowercased.
|
|
26
|
+
|
|
27
|
+
Deliberately model-independent so a metric never depends on which tokenizer
|
|
28
|
+
happened to be loaded.
|
|
29
|
+
|
|
30
|
+
>>> tokenize_words("Hello, world! Hello.")
|
|
31
|
+
['hello', ',', 'world', '!', 'hello', '.']
|
|
32
|
+
"""
|
|
33
|
+
return _WORD_RE.findall(text.lower())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def distinct_n(tokens: Sequence[str], n: int = 2) -> float:
|
|
37
|
+
"""Ratio of unique n-grams to total n-grams; 1.0 means no repetition.
|
|
38
|
+
|
|
39
|
+
Returns 0.0 when the text is too short to contain an n-gram.
|
|
40
|
+
"""
|
|
41
|
+
if n <= 0:
|
|
42
|
+
raise ValueError(f"n must be positive, got {n}")
|
|
43
|
+
if len(tokens) < n:
|
|
44
|
+
return 0.0
|
|
45
|
+
grams = [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
|
|
46
|
+
return len(set(grams)) / len(grams)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def repetition_rate(tokens: Sequence[str], n: int = 3) -> float:
|
|
50
|
+
"""Fraction of n-grams that occur more than once.
|
|
51
|
+
|
|
52
|
+
The complement of a "novel n-gram" rate. High values indicate looping.
|
|
53
|
+
"""
|
|
54
|
+
if len(tokens) < n:
|
|
55
|
+
return 0.0
|
|
56
|
+
grams = [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
|
|
57
|
+
counts: dict[tuple[str, ...], int] = {}
|
|
58
|
+
for gram in grams:
|
|
59
|
+
counts[gram] = counts.get(gram, 0) + 1
|
|
60
|
+
repeated = sum(count for count in counts.values() if count > 1)
|
|
61
|
+
return repeated / len(grams)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def longest_repeated_ngram(tokens: Sequence[str], max_n: int = 20) -> int:
|
|
65
|
+
"""Length of the longest n-gram that appears at least twice.
|
|
66
|
+
|
|
67
|
+
A blunt but reliable degeneration detector: healthy prose rarely repeats a
|
|
68
|
+
10-gram, a looping model repeats one constantly.
|
|
69
|
+
"""
|
|
70
|
+
best = 0
|
|
71
|
+
upper = min(max_n, len(tokens) // 2)
|
|
72
|
+
for n in range(1, upper + 1):
|
|
73
|
+
seen: set[tuple[str, ...]] = set()
|
|
74
|
+
found = False
|
|
75
|
+
for i in range(len(tokens) - n + 1):
|
|
76
|
+
gram = tuple(tokens[i : i + n])
|
|
77
|
+
if gram in seen:
|
|
78
|
+
found = True
|
|
79
|
+
break
|
|
80
|
+
seen.add(gram)
|
|
81
|
+
if found:
|
|
82
|
+
best = n
|
|
83
|
+
else:
|
|
84
|
+
# No repeat of length n means no repeat of any longer length.
|
|
85
|
+
break
|
|
86
|
+
return best
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def most_common_ngram_fraction(tokens: Sequence[str], n: int = 2) -> float:
|
|
90
|
+
"""Share of all n-grams taken by the single most frequent one.
|
|
91
|
+
|
|
92
|
+
Added after an observed miss: at high steering strength the model produced
|
|
93
|
+
``"as they encounter each other, as they interact with each other, as they
|
|
94
|
+
collide, as they merge, ..."`` -- obviously degenerate, yet it passed the
|
|
95
|
+
distinct-n and longest-repeat checks because each clause ends differently.
|
|
96
|
+
A single bigram occupying ~18% of all bigrams catches that pattern, where
|
|
97
|
+
set-based diversity measures do not.
|
|
98
|
+
"""
|
|
99
|
+
if len(tokens) < n:
|
|
100
|
+
return 0.0
|
|
101
|
+
grams = [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
|
|
102
|
+
counts: dict[tuple[str, ...], int] = {}
|
|
103
|
+
for gram in grams:
|
|
104
|
+
counts[gram] = counts.get(gram, 0) + 1
|
|
105
|
+
return max(counts.values()) / len(grams)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def type_token_ratio(tokens: Sequence[str]) -> float:
|
|
109
|
+
"""Unique tokens / total tokens. 0.0 for empty input."""
|
|
110
|
+
if not tokens:
|
|
111
|
+
return 0.0
|
|
112
|
+
return len(set(tokens)) / len(tokens)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def shannon_entropy(tokens: Sequence[str]) -> float:
|
|
116
|
+
"""Unigram entropy in bits. Collapsed output has near-zero entropy."""
|
|
117
|
+
if not tokens:
|
|
118
|
+
return 0.0
|
|
119
|
+
counts: dict[str, int] = {}
|
|
120
|
+
for token in tokens:
|
|
121
|
+
counts[token] = counts.get(token, 0) + 1
|
|
122
|
+
total = len(tokens)
|
|
123
|
+
return -sum((c / total) * math.log2(c / total) for c in counts.values())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class GenerationMetrics:
|
|
128
|
+
"""Model-free summary of one generated string."""
|
|
129
|
+
|
|
130
|
+
num_chars: int
|
|
131
|
+
num_words: int
|
|
132
|
+
distinct_1: float
|
|
133
|
+
distinct_2: float
|
|
134
|
+
distinct_3: float
|
|
135
|
+
repetition_3: float
|
|
136
|
+
longest_repeat: int
|
|
137
|
+
type_token_ratio: float
|
|
138
|
+
entropy: float
|
|
139
|
+
top_bigram_fraction: float
|
|
140
|
+
is_empty: bool
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def degeneration_flag(self) -> bool:
|
|
144
|
+
"""Heuristic tripwire for obviously broken output.
|
|
145
|
+
|
|
146
|
+
Thresholds are conservative: they fire on text that is plainly looping
|
|
147
|
+
or empty, not on text that is merely repetitive. A True here means
|
|
148
|
+
"inspect this generation", not "this intervention failed".
|
|
149
|
+
|
|
150
|
+
These are heuristics, and they have been observed to miss things -- the
|
|
151
|
+
``top_bigram_fraction`` clause exists because the earlier rules scored a
|
|
152
|
+
clearly-looping generation as clean. Treat a False as "no obvious
|
|
153
|
+
breakage detected", not as "output is fine".
|
|
154
|
+
"""
|
|
155
|
+
if self.is_empty:
|
|
156
|
+
return True
|
|
157
|
+
if self.num_words >= 30 and self.distinct_2 < 0.35:
|
|
158
|
+
return True
|
|
159
|
+
if self.longest_repeat >= 10:
|
|
160
|
+
return True
|
|
161
|
+
if self.num_words >= 20 and self.entropy < 2.0:
|
|
162
|
+
return True
|
|
163
|
+
if self.num_words >= 30 and self.top_bigram_fraction > 0.10:
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
data = asdict(self)
|
|
169
|
+
data["degeneration_flag"] = self.degeneration_flag
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def score_generation(text: str) -> GenerationMetrics:
|
|
174
|
+
"""Compute all model-free metrics for one generated string."""
|
|
175
|
+
tokens = tokenize_words(text)
|
|
176
|
+
return GenerationMetrics(
|
|
177
|
+
num_chars=len(text),
|
|
178
|
+
num_words=len(tokens),
|
|
179
|
+
distinct_1=distinct_n(tokens, 1),
|
|
180
|
+
distinct_2=distinct_n(tokens, 2),
|
|
181
|
+
distinct_3=distinct_n(tokens, 3),
|
|
182
|
+
repetition_3=repetition_rate(tokens, 3),
|
|
183
|
+
longest_repeat=longest_repeated_ngram(tokens),
|
|
184
|
+
type_token_ratio=type_token_ratio(tokens),
|
|
185
|
+
entropy=shannon_entropy(tokens),
|
|
186
|
+
top_bigram_fraction=most_common_ngram_fraction(tokens, 2),
|
|
187
|
+
is_empty=not text.strip(),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def jaccard_similarity(a: str, b: str, n: int = 3) -> float:
|
|
192
|
+
"""n-gram Jaccard overlap between two strings, in ``[0, 1]``.
|
|
193
|
+
|
|
194
|
+
Used to quantify *semantic drift*: how far an intervened generation moved
|
|
195
|
+
from its baseline. 1.0 means identical n-gram sets.
|
|
196
|
+
"""
|
|
197
|
+
ta, tb = tokenize_words(a), tokenize_words(b)
|
|
198
|
+
if len(ta) < n or len(tb) < n:
|
|
199
|
+
return 1.0 if a.strip() == b.strip() else 0.0
|
|
200
|
+
ga = {tuple(ta[i : i + n]) for i in range(len(ta) - n + 1)}
|
|
201
|
+
gb = {tuple(tb[i : i + n]) for i in range(len(tb) - n + 1)}
|
|
202
|
+
union = ga | gb
|
|
203
|
+
if not union:
|
|
204
|
+
return 1.0
|
|
205
|
+
return len(ga & gb) / len(union)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def compare_generations(baseline: str, intervened: str) -> dict[str, Any]:
|
|
209
|
+
"""Side-by-side comparison of a baseline and an intervened generation."""
|
|
210
|
+
base_metrics = score_generation(baseline)
|
|
211
|
+
int_metrics = score_generation(intervened)
|
|
212
|
+
return {
|
|
213
|
+
"baseline": base_metrics.to_dict(),
|
|
214
|
+
"intervened": int_metrics.to_dict(),
|
|
215
|
+
"identical": baseline == intervened,
|
|
216
|
+
"jaccard_3": jaccard_similarity(baseline, intervened, n=3),
|
|
217
|
+
"length_ratio": (
|
|
218
|
+
int_metrics.num_words / base_metrics.num_words if base_metrics.num_words else None
|
|
219
|
+
),
|
|
220
|
+
"distinct_2_delta": int_metrics.distinct_2 - base_metrics.distinct_2,
|
|
221
|
+
"entropy_delta": int_metrics.entropy - base_metrics.entropy,
|
|
222
|
+
"degeneration_introduced": int_metrics.degeneration_flag and not base_metrics.degeneration_flag,
|
|
223
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The portable BrainPatch artifact: format, loading, validation, registry.
|
|
2
|
+
|
|
3
|
+
Everything here is importable with **no ML stack installed**. Parsing a patch,
|
|
4
|
+
checking its compatibility metadata, installing it, and reporting its size all
|
|
5
|
+
work on a bare Python 3.10+.
|
|
6
|
+
|
|
7
|
+
The one exception is :mod:`brainpatch.patch.compiler`, which reads SAE
|
|
8
|
+
checkpoints and therefore needs torch. It is not imported here; import it
|
|
9
|
+
explicitly when you mean to compile.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from brainpatch.patch.format import (
|
|
13
|
+
ABSOLUTE_MAX_STRENGTH,
|
|
14
|
+
FORMAT_VERSION,
|
|
15
|
+
SUFFIX,
|
|
16
|
+
BaseModelSpec,
|
|
17
|
+
Intervention,
|
|
18
|
+
Manifest,
|
|
19
|
+
PatchFormatError,
|
|
20
|
+
)
|
|
21
|
+
from brainpatch.patch.loader import (
|
|
22
|
+
LoadedPatch,
|
|
23
|
+
PatchLoadError,
|
|
24
|
+
load_patch,
|
|
25
|
+
patch_size_report,
|
|
26
|
+
save_patch,
|
|
27
|
+
)
|
|
28
|
+
from brainpatch.patch.registry import (
|
|
29
|
+
InstalledPatch,
|
|
30
|
+
PatchRegistry,
|
|
31
|
+
RegistryError,
|
|
32
|
+
default_registry,
|
|
33
|
+
registry_home,
|
|
34
|
+
)
|
|
35
|
+
from brainpatch.patch.validation import (
|
|
36
|
+
CompatibilityReport,
|
|
37
|
+
ModelDescriptor,
|
|
38
|
+
PatchCompatibilityError,
|
|
39
|
+
check_compatibility,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ABSOLUTE_MAX_STRENGTH",
|
|
44
|
+
"BaseModelSpec",
|
|
45
|
+
"CompatibilityReport",
|
|
46
|
+
"FORMAT_VERSION",
|
|
47
|
+
"InstalledPatch",
|
|
48
|
+
"Intervention",
|
|
49
|
+
"LoadedPatch",
|
|
50
|
+
"Manifest",
|
|
51
|
+
"ModelDescriptor",
|
|
52
|
+
"PatchCompatibilityError",
|
|
53
|
+
"PatchFormatError",
|
|
54
|
+
"PatchLoadError",
|
|
55
|
+
"PatchRegistry",
|
|
56
|
+
"RegistryError",
|
|
57
|
+
"SUFFIX",
|
|
58
|
+
"check_compatibility",
|
|
59
|
+
"default_registry",
|
|
60
|
+
"load_patch",
|
|
61
|
+
"patch_size_report",
|
|
62
|
+
"registry_home",
|
|
63
|
+
"save_patch",
|
|
64
|
+
]
|