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
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
+ ]