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,50 @@
1
+ """The BrainPatch runtime: backends, capabilities, scheduling, and the model API.
2
+
3
+ This subpackage is **infrastructure-independent**. It knows how to apply a
4
+ vector to a layer of a frozen model; it knows nothing about SAEs, activation
5
+ corpora, Modal, or how the patch it is applying came to exist.
6
+
7
+ That separation is the product: research happens wherever the author has GPUs,
8
+ and the resulting artifact runs on the user's own machine with whatever
9
+ inference engine they already use.
10
+
11
+ ``brainpatch.runtime.model`` and ``brainpatch.runtime.base`` are importable with
12
+ no ML stack present; the engine-specific code under
13
+ :mod:`brainpatch.backends` imports torch/vllm/llama.cpp only when instantiated.
14
+ """
15
+
16
+ from brainpatch.runtime.auto import (
17
+ BackendNotAvailable,
18
+ BackendStatus,
19
+ available_backends,
20
+ backend_class,
21
+ environment_report,
22
+ select_backend,
23
+ )
24
+ from brainpatch.runtime.base import (
25
+ ActivePatch,
26
+ BrainPatchBackend,
27
+ GenerationConfig,
28
+ ResolvedEdit,
29
+ )
30
+ from brainpatch.runtime.capabilities import CAPABILITY_FLAGS, Capabilities
31
+ from brainpatch.runtime.model import BrainPatchedModel, PatchHandle
32
+ from brainpatch.runtime.scheduling import StrengthSchedule
33
+
34
+ __all__ = [
35
+ "ActivePatch",
36
+ "BackendNotAvailable",
37
+ "BackendStatus",
38
+ "BrainPatchBackend",
39
+ "BrainPatchedModel",
40
+ "CAPABILITY_FLAGS",
41
+ "Capabilities",
42
+ "GenerationConfig",
43
+ "PatchHandle",
44
+ "ResolvedEdit",
45
+ "StrengthSchedule",
46
+ "available_backends",
47
+ "backend_class",
48
+ "environment_report",
49
+ "select_backend",
50
+ ]
@@ -0,0 +1,157 @@
1
+ """Backend discovery, selection, and the ``doctor`` report.
2
+
3
+ Every backend is imported lazily and probed defensively: ``brainpatch doctor``
4
+ has to work on a machine with none of the engines installed, and its whole job
5
+ is to say *which* are missing and what to do about it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import platform
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from brainpatch.runtime.base import BrainPatchBackend
17
+ from brainpatch.runtime.capabilities import Capabilities
18
+
19
+ #: backend name -> module path. Order is preference order for ``backend="auto"``.
20
+ BACKEND_MODULES: dict[str, str] = {
21
+ "transformers": "brainpatch.backends.transformers_backend",
22
+ "llamacpp": "brainpatch.backends.llamacpp",
23
+ "vllm": "brainpatch.backends.vllm_backend",
24
+ "mlx": "brainpatch.backends.mlx_backend",
25
+ }
26
+
27
+ #: Friendly aliases users actually type.
28
+ BACKEND_ALIASES: dict[str, str] = {
29
+ "hf": "transformers",
30
+ "torch": "transformers",
31
+ "pytorch": "transformers",
32
+ "llama.cpp": "llamacpp",
33
+ "llama_cpp": "llamacpp",
34
+ "gguf": "llamacpp",
35
+ "mlx-lm": "mlx",
36
+ "mlx_lm": "mlx",
37
+ }
38
+
39
+
40
+ class BackendNotAvailable(RuntimeError):
41
+ """The requested backend cannot run in this environment."""
42
+
43
+
44
+ def normalize_backend_name(name: str) -> str:
45
+ key = name.strip().lower()
46
+ return BACKEND_ALIASES.get(key, key)
47
+
48
+
49
+ def backend_class(name: str) -> type[BrainPatchBackend]:
50
+ """Import and return a backend class by name."""
51
+ key = normalize_backend_name(name)
52
+ if key not in BACKEND_MODULES:
53
+ raise BackendNotAvailable(
54
+ f"unknown backend {name!r}. Known: {', '.join(sorted(BACKEND_MODULES))}"
55
+ )
56
+ module = importlib.import_module(BACKEND_MODULES[key])
57
+ cls = getattr(module, "BACKEND", None)
58
+ if cls is None: # pragma: no cover - guards a malformed backend module
59
+ raise BackendNotAvailable(f"backend module for {key!r} exposes no BACKEND class")
60
+ return cls
61
+
62
+
63
+ @dataclass
64
+ class BackendStatus:
65
+ """One row of the doctor report."""
66
+
67
+ name: str
68
+ available: bool
69
+ detail: str
70
+ capabilities: Capabilities | None = None
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return {
74
+ "backend": self.name,
75
+ "available": self.available,
76
+ "detail": self.detail,
77
+ "capabilities": self.capabilities.to_dict() if self.capabilities else None,
78
+ }
79
+
80
+
81
+ def probe_backend(name: str) -> BackendStatus:
82
+ """Check one backend without raising, whatever is or is not installed."""
83
+ key = normalize_backend_name(name)
84
+ try:
85
+ cls = backend_class(key)
86
+ except BackendNotAvailable as exc:
87
+ return BackendStatus(name=key, available=False, detail=str(exc))
88
+ except Exception as exc: # noqa: BLE001 - a broken backend must not break doctor
89
+ return BackendStatus(name=key, available=False, detail=f"import failed: {exc}")
90
+
91
+ try:
92
+ available, detail = cls.is_available()
93
+ except Exception as exc: # noqa: BLE001
94
+ return BackendStatus(name=key, available=False, detail=f"probe failed: {exc}")
95
+
96
+ try:
97
+ caps = cls.capabilities()
98
+ except Exception: # noqa: BLE001
99
+ caps = None
100
+ return BackendStatus(name=key, available=available, detail=detail, capabilities=caps)
101
+
102
+
103
+ def available_backends() -> list[BackendStatus]:
104
+ """Probe every known backend, in preference order."""
105
+ return [probe_backend(name) for name in BACKEND_MODULES]
106
+
107
+
108
+ def select_backend(preferred: str = "auto") -> type[BrainPatchBackend]:
109
+ """Resolve ``preferred`` to a usable backend class.
110
+
111
+ ``"auto"`` picks the first available backend in preference order. An
112
+ explicitly named backend that is unavailable raises with the reason, rather
113
+ than silently falling back to a different engine -- a silent substitution
114
+ would make "I tested it on vLLM" untrue.
115
+ """
116
+ if preferred != "auto":
117
+ key = normalize_backend_name(preferred)
118
+ cls = backend_class(key)
119
+ ok, detail = cls.is_available()
120
+ if not ok:
121
+ raise BackendNotAvailable(f"backend {key!r} is not available: {detail}")
122
+ return cls
123
+
124
+ problems: list[str] = []
125
+ for name in BACKEND_MODULES:
126
+ status = probe_backend(name)
127
+ if status.available:
128
+ return backend_class(name)
129
+ problems.append(f" {name}: {status.detail}")
130
+ raise BackendNotAvailable(
131
+ "no inference backend is available. Install one:\n"
132
+ " pip install 'brainpatch[transformers]'\n\n"
133
+ "Probed:\n" + "\n".join(problems)
134
+ )
135
+
136
+
137
+ def environment_report() -> dict[str, Any]:
138
+ """Everything ``brainpatch doctor`` prints."""
139
+ from brainpatch import __version__
140
+ from brainpatch.patch.registry import default_registry
141
+
142
+ registry = default_registry()
143
+ try:
144
+ installed = [p.name for p in registry.list_patches()]
145
+ except OSError:
146
+ installed = []
147
+
148
+ return {
149
+ "brainpatch_version": __version__,
150
+ "python": sys.version.split()[0],
151
+ "platform": platform.platform(),
152
+ "machine": platform.machine(),
153
+ "is_apple_silicon": platform.system() == "Darwin" and platform.machine() == "arm64",
154
+ "registry_home": str(registry.home),
155
+ "installed_patches": installed,
156
+ "backends": [s.to_dict() for s in available_backends()],
157
+ }
@@ -0,0 +1,311 @@
1
+ """The backend contract every inference engine adapter implements.
2
+
3
+ Design rule: this module imports nothing heavy. It defines the interface and the
4
+ bookkeeping that is identical across engines -- which patches are installed, what
5
+ their live strength is, how a schedule resolves at token *n* -- so each backend
6
+ only has to implement the part that is genuinely engine-specific: loading a
7
+ model, injecting a vector, and generating.
8
+
9
+ That split is what keeps the tricky logic (strength resolution, the guarantee
10
+ that strength 0 is exactly baseline) in one tested place rather than
11
+ reimplemented four times with four different bugs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Iterator
19
+
20
+ from brainpatch.patch.format import Manifest
21
+ from brainpatch.patch.loader import LoadedPatch
22
+ from brainpatch.patch.validation import (
23
+ CompatibilityMode,
24
+ CompatibilityReport,
25
+ ModelDescriptor,
26
+ check_compatibility,
27
+ )
28
+ from brainpatch.runtime.capabilities import Capabilities
29
+ from brainpatch.runtime.scheduling import StrengthSchedule
30
+
31
+ #: Coefficients below this are treated as exactly zero, so a zeroed patch is
32
+ #: bit-identical to baseline rather than merely close to it.
33
+ STRENGTH_EPSILON = 1e-12
34
+
35
+
36
+ @dataclass
37
+ class GenerationConfig:
38
+ """Sampling settings, shared verbatim across compared conditions."""
39
+
40
+ max_new_tokens: int = 128
41
+ temperature: float = 0.0
42
+ top_p: float = 1.0
43
+ top_k: int = 0
44
+ repetition_penalty: float = 1.0
45
+ seed: int = 0
46
+ stop: list[str] = field(default_factory=list)
47
+
48
+ @property
49
+ def do_sample(self) -> bool:
50
+ return self.temperature > 0.0
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ return {
54
+ "max_new_tokens": self.max_new_tokens,
55
+ "temperature": self.temperature,
56
+ "top_p": self.top_p,
57
+ "top_k": self.top_k,
58
+ "repetition_penalty": self.repetition_penalty,
59
+ "seed": self.seed,
60
+ "stop": list(self.stop),
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class ActivePatch:
66
+ """An installed patch plus its live, user-controllable state."""
67
+
68
+ patch: LoadedPatch
69
+ strength: float = 1.0
70
+ enabled: bool = True
71
+ schedule: StrengthSchedule | None = None
72
+
73
+ def __post_init__(self) -> None:
74
+ if self.schedule is None and self.patch.manifest.schedule is not None:
75
+ self.schedule = StrengthSchedule.from_dict(self.patch.manifest.schedule)
76
+
77
+ @property
78
+ def name(self) -> str:
79
+ return self.patch.manifest.name
80
+
81
+ @property
82
+ def manifest(self) -> Manifest:
83
+ return self.patch.manifest
84
+
85
+ def multiplier_at(self, token_index: int) -> float:
86
+ """Live multiplier at a generated-token index, clamped to the envelope."""
87
+ if not self.enabled:
88
+ return 0.0
89
+ value = self.manifest.clamp_strength(self.strength)
90
+ if self.schedule is not None:
91
+ value *= self.schedule.strength_at(token_index)
92
+ return value
93
+
94
+
95
+ @dataclass
96
+ class ResolvedEdit:
97
+ """One vector to add, with its final coefficient, at one layer."""
98
+
99
+ layer: int
100
+ hook: str
101
+ vector_key: str
102
+ coefficient: float
103
+ patch_name: str
104
+
105
+
106
+ class BrainPatchBackend(ABC):
107
+ """Common interface across Transformers, llama.cpp, vLLM and MLX."""
108
+
109
+ #: Short identifier used by ``--backend`` and in capability tables.
110
+ name: str = "abstract"
111
+
112
+ def __init__(self) -> None:
113
+ self._patches: dict[str, ActivePatch] = {}
114
+ self._compatibility_mode: CompatibilityMode = "strict"
115
+
116
+ # -- engine-specific -------------------------------------------------------
117
+
118
+ @classmethod
119
+ @abstractmethod
120
+ def is_available(cls) -> tuple[bool, str]:
121
+ """``(available, reason)``. Must not raise, even with nothing installed."""
122
+
123
+ @classmethod
124
+ @abstractmethod
125
+ def capabilities(cls) -> Capabilities:
126
+ """What this backend can do. Callable without the engine installed."""
127
+
128
+ @abstractmethod
129
+ def load_model(self, model: str, **kwargs: Any) -> None:
130
+ """Load the frozen base model. Never modifies weights on disk."""
131
+
132
+ @abstractmethod
133
+ def describe_model(self) -> ModelDescriptor:
134
+ """Architecture facts discovered from the loaded model."""
135
+
136
+ @abstractmethod
137
+ def generate(self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any) -> str:
138
+ """Generate a completion with all enabled patches active."""
139
+
140
+ def stream(
141
+ self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
142
+ ) -> Iterator[str]:
143
+ """Yield incremental text. Backends without streaming yield once."""
144
+ self.capabilities().require("streaming")
145
+ yield self.generate(prompt, config, **kwargs)
146
+
147
+ # -- shared patch bookkeeping ---------------------------------------------
148
+
149
+ @property
150
+ def patches(self) -> dict[str, ActivePatch]:
151
+ return self._patches
152
+
153
+ def validate_patch(
154
+ self, patch: LoadedPatch, *, mode: CompatibilityMode | None = None
155
+ ) -> CompatibilityReport:
156
+ return check_compatibility(
157
+ patch.manifest,
158
+ self.describe_model(),
159
+ mode=mode or self._compatibility_mode,
160
+ )
161
+
162
+ def install_patch(
163
+ self,
164
+ patch: LoadedPatch,
165
+ *,
166
+ strength: float | None = None,
167
+ mode: CompatibilityMode | None = None,
168
+ ) -> ActivePatch:
169
+ """Validate then register a patch. Nothing changes if validation fails."""
170
+ report = self.validate_patch(patch, mode=mode)
171
+ report.raise_if_failed()
172
+ for warning in report.warnings:
173
+ self._warn(warning)
174
+
175
+ backend_status = patch.manifest.backend_status(self.name)
176
+ if backend_status in {"unsupported", "implemented"}:
177
+ self._warn(
178
+ f"patch {patch.manifest.name!r} declares backend '{self.name}' as "
179
+ f"'{backend_status}' -- it has not been verified on this engine."
180
+ )
181
+
182
+ active = ActivePatch(
183
+ patch=patch,
184
+ strength=(
185
+ patch.manifest.default_strength if strength is None else float(strength)
186
+ ),
187
+ )
188
+ if len(self._patches) >= 1 and not self.capabilities().multiple_patches:
189
+ existing = next(iter(self._patches))
190
+ if existing != active.name:
191
+ raise NotImplementedError(
192
+ f"the {self.name!r} backend supports one patch at a time; "
193
+ f"{existing!r} is already installed"
194
+ )
195
+ self._patches[active.name] = active
196
+ self._on_patches_changed()
197
+ return active
198
+
199
+ def remove_patch(self, name: str) -> None:
200
+ if name not in self._patches:
201
+ raise KeyError(f"no patch named {name!r} is installed on this backend")
202
+ del self._patches[name]
203
+ self._on_patches_changed()
204
+
205
+ def set_strength(self, name: str, strength: float) -> float:
206
+ """Set live strength; returns the value after clamping."""
207
+ active = self._require(name)
208
+ clamped = active.manifest.clamp_strength(strength)
209
+ if clamped != float(strength):
210
+ self._warn(
211
+ f"strength {strength} clamped to {clamped} by patch "
212
+ f"{name!r} (max_abs_strength={active.manifest.max_abs_strength})"
213
+ )
214
+ active.strength = clamped
215
+ self._on_patches_changed()
216
+ return clamped
217
+
218
+ def set_enabled(self, name: str, enabled: bool) -> None:
219
+ self._require(name).enabled = bool(enabled)
220
+ self._on_patches_changed()
221
+
222
+ def set_schedule(self, name: str, schedule: StrengthSchedule | dict[int, float] | None) -> None:
223
+ self.capabilities().require("dynamic_schedule")
224
+ active = self._require(name)
225
+ if isinstance(schedule, dict):
226
+ schedule = StrengthSchedule(schedule)
227
+ active.schedule = schedule
228
+ self._on_patches_changed()
229
+
230
+ def list_patches(self) -> list[str]:
231
+ return list(self._patches)
232
+
233
+ def _require(self, name: str) -> ActivePatch:
234
+ if name not in self._patches:
235
+ installed = ", ".join(self._patches) or "none"
236
+ raise KeyError(f"no patch named {name!r} is installed (installed: {installed})")
237
+ return self._patches[name]
238
+
239
+ # -- edit resolution -------------------------------------------------------
240
+
241
+ def resolve_edits(
242
+ self,
243
+ token_index: int = 0,
244
+ layer: int | None = None,
245
+ *,
246
+ is_prompt_pass: bool | None = None,
247
+ ) -> list[ResolvedEdit]:
248
+ """Vectors to add at a generated-token index.
249
+
250
+ An empty list is the signal to leave activations completely untouched.
251
+ That is what makes strength 0 identical to baseline rather than a no-op
252
+ addition that still round-trips through floating point.
253
+
254
+ ``is_prompt_pass`` lets an intervention restrict itself to the prompt or
255
+ to generated tokens. Backends that cannot distinguish the two pass
256
+ ``None``, in which case every intervention applies and the caller is
257
+ responsible for knowing that a site-restricted patch is being applied
258
+ more broadly than it was measured.
259
+ """
260
+ edits: list[ResolvedEdit] = []
261
+ for active in self._patches.values():
262
+ multiplier = active.multiplier_at(token_index)
263
+ if abs(multiplier) < STRENGTH_EPSILON:
264
+ continue
265
+ for intervention in active.manifest.interventions:
266
+ if layer is not None and intervention.layer != layer:
267
+ continue
268
+ site = getattr(intervention, "site", "all")
269
+ if is_prompt_pass is not None and site != "all":
270
+ if site == "prompt" and not is_prompt_pass:
271
+ continue
272
+ if site == "continuation" and is_prompt_pass:
273
+ continue
274
+ coefficient = multiplier * intervention.coefficient
275
+ if abs(coefficient) < STRENGTH_EPSILON:
276
+ continue
277
+ edits.append(
278
+ ResolvedEdit(
279
+ layer=intervention.layer,
280
+ hook=intervention.hook,
281
+ vector_key=intervention.vector,
282
+ coefficient=coefficient,
283
+ patch_name=active.name,
284
+ )
285
+ )
286
+ return edits
287
+
288
+ def active_layers(self) -> list[int]:
289
+ return sorted({e.layer for e in self.resolve_edits(0)})
290
+
291
+ def vector_values(self, patch_name: str, key: str) -> list[float]:
292
+ return list(self._require(patch_name).patch.vector_for(key).data)
293
+
294
+ # -- hooks for subclasses --------------------------------------------------
295
+
296
+ def _on_patches_changed(self) -> None:
297
+ """Called after any patch-state mutation. Override to rebuild caches."""
298
+
299
+ def _warn(self, message: str) -> None:
300
+ import warnings
301
+
302
+ warnings.warn(f"[brainpatch:{self.name}] {message}", stacklevel=3)
303
+
304
+ def unload(self) -> None:
305
+ """Release engine resources. Safe to call more than once."""
306
+
307
+ def __enter__(self) -> "BrainPatchBackend":
308
+ return self
309
+
310
+ def __exit__(self, *exc: Any) -> None:
311
+ self.unload()
@@ -0,0 +1,96 @@
1
+ """Backend capability reporting.
2
+
3
+ Backends genuinely differ in what they can do, and the honest thing is to say so
4
+ rather than emulate a feature badly. llama.cpp applies a control vector for a
5
+ whole run; vLLM batches concurrent requests with shared model state; MLX has no
6
+ CI hardware here. Each of those is a real constraint, and a user deserves to
7
+ know before they design around a capability that is not there.
8
+
9
+ :class:`Capabilities` is what ``brainpatch backends`` and ``brainpatch doctor``
10
+ print, and what the runtime consults before accepting an operation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import asdict, dataclass, field
16
+ from typing import Any
17
+
18
+ #: Every capability flag, in display order.
19
+ CAPABILITY_FLAGS: tuple[str, ...] = (
20
+ "static_intervention",
21
+ "dynamic_schedule",
22
+ "multiple_patches",
23
+ "streaming",
24
+ "cpu",
25
+ "cuda",
26
+ "mps",
27
+ "apple_silicon",
28
+ "server",
29
+ "concurrent_requests",
30
+ "quantization",
31
+ "per_request_strength",
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class Capabilities:
37
+ """What a backend can actually do.
38
+
39
+ Defaults are all False on purpose: a backend must opt in to each claim, so a
40
+ forgotten flag understates rather than overstates support.
41
+ """
42
+
43
+ name: str
44
+
45
+ static_intervention: bool = False
46
+ """Apply a fixed-strength patch for a whole generation."""
47
+
48
+ dynamic_schedule: bool = False
49
+ """Change strength between generated tokens."""
50
+
51
+ multiple_patches: bool = False
52
+ streaming: bool = False
53
+
54
+ cpu: bool = False
55
+ cuda: bool = False
56
+ mps: bool = False
57
+ apple_silicon: bool = False
58
+
59
+ server: bool = False
60
+ concurrent_requests: bool = False
61
+ per_request_strength: bool = False
62
+
63
+ quantization: tuple[str, ...] = ()
64
+ """Quantization formats actually exercised, e.g. ``("Q4_K_M",)``."""
65
+
66
+ notes: dict[str, str] = field(default_factory=dict)
67
+ """Per-capability explanation, especially for the False ones."""
68
+
69
+ def supports(self, flag: str) -> bool:
70
+ if flag not in CAPABILITY_FLAGS:
71
+ raise ValueError(f"unknown capability {flag!r}; expected one of {CAPABILITY_FLAGS}")
72
+ value = getattr(self, flag)
73
+ return bool(value)
74
+
75
+ def require(self, flag: str) -> None:
76
+ """Raise a useful error if the backend cannot do ``flag``."""
77
+ if not self.supports(flag):
78
+ note = self.notes.get(flag)
79
+ detail = f" {note}" if note else ""
80
+ raise NotImplementedError(
81
+ f"the {self.name!r} backend does not support {flag!r}.{detail}"
82
+ )
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ data = asdict(self)
86
+ data["quantization"] = list(self.quantization)
87
+ return data
88
+
89
+ def matrix_row(self) -> dict[str, Any]:
90
+ """Compact row for the capability matrix table."""
91
+ row: dict[str, Any] = {"backend": self.name}
92
+ for flag in CAPABILITY_FLAGS:
93
+ value = getattr(self, flag)
94
+ row[flag] = bool(value) if not isinstance(value, tuple) else bool(value)
95
+ row["quantization"] = ", ".join(self.quantization) or "-"
96
+ return row