brainpatch 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brainpatch/__init__.py +92 -0
- brainpatch/backends/__init__.py +19 -0
- brainpatch/backends/llamacpp.py +383 -0
- brainpatch/backends/mlx_backend.py +213 -0
- brainpatch/backends/transformers_backend.py +473 -0
- brainpatch/backends/vllm_backend.py +299 -0
- brainpatch/backends/vllm_worker.py +129 -0
- brainpatch/cli.py +825 -0
- brainpatch/config.py +245 -0
- brainpatch/datasets/__init__.py +20 -0
- brainpatch/datasets/contrast_sets.py +64 -0
- brainpatch/evaluation/__init__.py +28 -0
- brainpatch/evaluation/metrics.py +223 -0
- brainpatch/patch/__init__.py +64 -0
- brainpatch/patch/compiler.py +324 -0
- brainpatch/patch/format.py +489 -0
- brainpatch/patch/loader.py +312 -0
- brainpatch/patch/registry.py +300 -0
- brainpatch/patch/tensors.py +236 -0
- brainpatch/patch/validation.py +157 -0
- brainpatch/paths.py +184 -0
- brainpatch/py.typed +0 -0
- brainpatch/research/__init__.py +16 -0
- brainpatch/research/antisycophancy.py +348 -0
- brainpatch/research/behaviour_eval.py +711 -0
- brainpatch/research/generation_eval.py +346 -0
- brainpatch/research/ml/__init__.py +35 -0
- brainpatch/research/ml/activation_store.py +232 -0
- brainpatch/research/ml/causal.py +386 -0
- brainpatch/research/ml/corpus.py +165 -0
- brainpatch/research/ml/evaluation.py +188 -0
- brainpatch/research/ml/extraction.py +464 -0
- brainpatch/research/ml/feature_analysis.py +317 -0
- brainpatch/research/ml/generation.py +109 -0
- brainpatch/research/ml/hooks.py +183 -0
- brainpatch/research/ml/intervention.py +274 -0
- brainpatch/research/ml/model.py +219 -0
- brainpatch/research/ml/patch_search.py +337 -0
- brainpatch/research/ml/runtime.py +343 -0
- brainpatch/research/ml/sae.py +383 -0
- brainpatch/research/ml/training.py +376 -0
- brainpatch/research/stance_rubric.py +170 -0
- brainpatch/research/sycophancy_data.py +982 -0
- brainpatch/research/sycophancy_data_r1.py +1701 -0
- brainpatch/research/sycophancy_data_v2.py +1649 -0
- brainpatch/research/sycophancy_data_v3.py +2288 -0
- brainpatch/research/sycophancy_v2_build.py +362 -0
- brainpatch/research/sycophancy_v3_build.py +188 -0
- brainpatch/research/utility_probe.py +139 -0
- brainpatch/runtime/__init__.py +50 -0
- brainpatch/runtime/auto.py +157 -0
- brainpatch/runtime/base.py +311 -0
- brainpatch/runtime/capabilities.py +96 -0
- brainpatch/runtime/model.py +260 -0
- brainpatch/runtime/scheduling.py +13 -0
- brainpatch/schemas/__init__.py +35 -0
- brainpatch/schemas/contrast.py +161 -0
- brainpatch/schemas/feature.py +193 -0
- brainpatch/schemas/manifest.py +167 -0
- brainpatch/schemas/patch.py +379 -0
- brainpatch/schemas/patch_io.py +88 -0
- brainpatch/schemas/sae.py +146 -0
- brainpatch/server/__init__.py +11 -0
- brainpatch/server/app.py +269 -0
- brainpatch/steering/__init__.py +13 -0
- brainpatch/steering/plan.py +177 -0
- brainpatch/steering/schedule.py +138 -0
- brainpatch/ui/__init__.py +11 -0
- brainpatch/ui/app.py +201 -0
- brainpatch/verify/__init__.py +66 -0
- brainpatch/verify/behavioural.py +156 -0
- brainpatch/verify/checks.py +204 -0
- brainpatch/verify/corruptions.py +335 -0
- brainpatch/verify/report.py +133 -0
- brainpatch/verify/vectors.py +95 -0
- brainpatch/verify/workflow.py +331 -0
- brainpatch-1.2.0.dist-info/METADATA +556 -0
- brainpatch-1.2.0.dist-info/RECORD +82 -0
- brainpatch-1.2.0.dist-info/WHEEL +5 -0
- brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
- brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
- brainpatch-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|