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,146 @@
|
|
|
1
|
+
"""Top-K sparse autoencoder configuration.
|
|
2
|
+
|
|
3
|
+
This module holds only the *description* of an SAE. The torch implementation
|
|
4
|
+
lives in :mod:`brainpatch.research.ml.sae` and is never imported locally.
|
|
5
|
+
|
|
6
|
+
Design notes captured here because they matter for correctness of downstream
|
|
7
|
+
interventions:
|
|
8
|
+
|
|
9
|
+
Input normalization
|
|
10
|
+
Residual-stream activations of different models/layers have wildly
|
|
11
|
+
different scales. We rescale inputs so that ``E[||x||_2] == sqrt(d_in)``
|
|
12
|
+
and store the resulting ``input_scale`` in the checkpoint. Any intervention
|
|
13
|
+
that injects a decoder direction back into the *raw* residual stream must
|
|
14
|
+
multiply by ``input_scale`` to undo the normalization -- otherwise a
|
|
15
|
+
"strength of 1.0" means something different for every SAE.
|
|
16
|
+
|
|
17
|
+
Decoder normalization
|
|
18
|
+
Decoder columns are constrained to unit L2 norm. Without this, the network
|
|
19
|
+
can trivially shrink the decoder and inflate feature activations (or vice
|
|
20
|
+
versa), which makes activation magnitudes -- and therefore any strength
|
|
21
|
+
parameter defined in terms of them -- meaningless. Unit-norm columns give
|
|
22
|
+
``strength`` a stable interpretation: "add ``strength * input_scale`` units
|
|
23
|
+
of length along this direction".
|
|
24
|
+
|
|
25
|
+
Dead features
|
|
26
|
+
Top-K SAEs reliably produce features that stop firing. We track a rolling
|
|
27
|
+
fire count and optionally apply the AuxK auxiliary loss (reconstruct the
|
|
28
|
+
residual error using only currently-dead features), which is the standard
|
|
29
|
+
mitigation from the OpenAI Top-K SAE work.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from dataclasses import asdict, dataclass, field
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
SAE_FORMAT_VERSION = "0.1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SAEConfig:
|
|
43
|
+
"""Architecture + training configuration of a Top-K SAE.
|
|
44
|
+
|
|
45
|
+
Attributes
|
|
46
|
+
----------
|
|
47
|
+
d_in:
|
|
48
|
+
Residual-stream width of the base model (e.g. 1536 for Qwen2.5-1.5B).
|
|
49
|
+
d_sae:
|
|
50
|
+
Dictionary size (number of learned features).
|
|
51
|
+
k:
|
|
52
|
+
Upper bound on features kept active per token by the Top-K operator.
|
|
53
|
+
``torch.topk`` always selects ``k`` indices, but the preceding ReLU can
|
|
54
|
+
make some of the selected values zero, so measured L0 is ``<= k`` rather
|
|
55
|
+
than identically ``k``.
|
|
56
|
+
normalize_decoder:
|
|
57
|
+
Constrain decoder columns to unit L2 norm after every optimizer step.
|
|
58
|
+
tied_init:
|
|
59
|
+
Initialise ``W_dec = W_enc.T``; a standard and stable starting point.
|
|
60
|
+
auxk_alpha:
|
|
61
|
+
Weight of the AuxK dead-feature revival loss. ``0.0`` disables it.
|
|
62
|
+
auxk_k:
|
|
63
|
+
How many dead features AuxK reconstructs the residual with.
|
|
64
|
+
dead_feature_window:
|
|
65
|
+
A feature is "dead" if it has not fired in this many training tokens.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
# architecture
|
|
69
|
+
d_in: int
|
|
70
|
+
d_sae: int
|
|
71
|
+
k: int = 32
|
|
72
|
+
normalize_decoder: bool = True
|
|
73
|
+
tied_init: bool = True
|
|
74
|
+
auxk_alpha: float = 1.0 / 32.0
|
|
75
|
+
auxk_k: int = 256
|
|
76
|
+
dead_feature_window: int = 200_000
|
|
77
|
+
|
|
78
|
+
# provenance of the activations this SAE is defined over
|
|
79
|
+
model: str = ""
|
|
80
|
+
model_revision: str = ""
|
|
81
|
+
layer: int = -1
|
|
82
|
+
hook: str = ""
|
|
83
|
+
|
|
84
|
+
# optimisation
|
|
85
|
+
lr: float = 3e-4
|
|
86
|
+
beta1: float = 0.9
|
|
87
|
+
beta2: float = 0.999
|
|
88
|
+
batch_size: int = 512
|
|
89
|
+
epochs: int = 1
|
|
90
|
+
max_steps: int | None = None
|
|
91
|
+
grad_clip: float = 1.0
|
|
92
|
+
lr_warmup_steps: int = 50
|
|
93
|
+
seed: int = 0
|
|
94
|
+
|
|
95
|
+
# data handling
|
|
96
|
+
shuffle_buffer: int = 8192
|
|
97
|
+
val_fraction: float = 0.05
|
|
98
|
+
|
|
99
|
+
#: Filled in during training: multiply raw activations by this to normalize.
|
|
100
|
+
#: ``None`` until measured on a sample of the corpus.
|
|
101
|
+
input_scale: float | None = None
|
|
102
|
+
|
|
103
|
+
format_version: str = SAE_FORMAT_VERSION
|
|
104
|
+
notes: dict[str, Any] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
def validate(self) -> None:
|
|
107
|
+
"""Raise :class:`ValueError` on an unusable configuration."""
|
|
108
|
+
if self.d_in <= 0:
|
|
109
|
+
raise ValueError(f"d_in must be positive, got {self.d_in}")
|
|
110
|
+
if self.d_sae <= 0:
|
|
111
|
+
raise ValueError(f"d_sae must be positive, got {self.d_sae}")
|
|
112
|
+
if not 0 < self.k <= self.d_sae:
|
|
113
|
+
raise ValueError(f"k must satisfy 0 < k <= d_sae, got k={self.k}, d_sae={self.d_sae}")
|
|
114
|
+
if self.auxk_alpha < 0:
|
|
115
|
+
raise ValueError(f"auxk_alpha must be non-negative, got {self.auxk_alpha}")
|
|
116
|
+
if self.auxk_k <= 0 or self.auxk_k > self.d_sae:
|
|
117
|
+
raise ValueError(f"auxk_k must satisfy 0 < auxk_k <= d_sae, got {self.auxk_k}")
|
|
118
|
+
if not 0.0 <= self.val_fraction < 1.0:
|
|
119
|
+
raise ValueError(f"val_fraction must be in [0, 1), got {self.val_fraction}")
|
|
120
|
+
if self.batch_size <= 0:
|
|
121
|
+
raise ValueError(f"batch_size must be positive, got {self.batch_size}")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def expansion_factor(self) -> float:
|
|
125
|
+
"""Dictionary size relative to the residual width."""
|
|
126
|
+
return self.d_sae / self.d_in
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def num_parameters(self) -> int:
|
|
130
|
+
"""Parameter count: encoder + decoder weights plus both bias vectors."""
|
|
131
|
+
return 2 * self.d_in * self.d_sae + self.d_sae + self.d_in
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, Any]:
|
|
134
|
+
return asdict(self)
|
|
135
|
+
|
|
136
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
137
|
+
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> "SAEConfig":
|
|
141
|
+
known = {f for f in cls.__dataclass_fields__} # noqa: SLF001
|
|
142
|
+
return cls(**{k: v for k, v in data.items() if k in known}) # type: ignore[arg-type]
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_json(cls, text: str) -> "SAEConfig":
|
|
146
|
+
return cls.from_dict(json.loads(text))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""OpenAI-compatible HTTP serving. Requires ``pip install 'brainpatch[server]'``."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["build_app"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "build_app":
|
|
8
|
+
from brainpatch.server.app import build_app
|
|
9
|
+
|
|
10
|
+
return build_app
|
|
11
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
brainpatch/server/app.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""OpenAI-compatible HTTP server.
|
|
2
|
+
|
|
3
|
+
Existing OpenAI clients work unchanged: point ``base_url`` at this server and
|
|
4
|
+
every request is served by the patched model. That is the whole point -- a
|
|
5
|
+
BrainPatch should slot into the stack an application already has.
|
|
6
|
+
|
|
7
|
+
Patch state is configured at **startup**, not per request. With a shared model
|
|
8
|
+
object, a per-request strength change would be visible to every other in-flight
|
|
9
|
+
request, so accepting one would be a correctness bug affecting other users'
|
|
10
|
+
output. The ``brainpatch`` extra field is therefore accepted only when it
|
|
11
|
+
matches the server's configuration, and rejected with a clear 400 otherwise,
|
|
12
|
+
rather than silently ignored.
|
|
13
|
+
|
|
14
|
+
Security posture: no patch loading over HTTP, no filesystem paths from request
|
|
15
|
+
bodies, no code execution, strengths clamped to each patch's declared envelope.
|
|
16
|
+
|
|
17
|
+
.. note::
|
|
18
|
+
This module deliberately has **no** ``from __future__ import annotations``
|
|
19
|
+
and defines its request models at module scope. FastAPI resolves parameter
|
|
20
|
+
annotations at runtime against the module's globals; with postponed
|
|
21
|
+
annotations and locally-defined models it cannot find them, silently demotes
|
|
22
|
+
the request body to a query parameter, and every POST fails with
|
|
23
|
+
``422 Field required``. Keep both properties as they are.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
from pydantic import BaseModel, Field
|
|
31
|
+
|
|
32
|
+
from brainpatch.runtime.base import GenerationConfig
|
|
33
|
+
|
|
34
|
+
#: Cap on tokens a single request may ask for, whatever it sends.
|
|
35
|
+
MAX_REQUEST_TOKENS = 4096
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChatMessage(BaseModel):
|
|
39
|
+
role: str
|
|
40
|
+
content: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ChatRequest(BaseModel):
|
|
44
|
+
messages: List[ChatMessage]
|
|
45
|
+
model: Optional[str] = None
|
|
46
|
+
max_tokens: Optional[int] = Field(default=None, ge=1, le=MAX_REQUEST_TOKENS)
|
|
47
|
+
temperature: float = Field(default=0.0, ge=0.0, le=2.0)
|
|
48
|
+
top_p: float = Field(default=1.0, gt=0.0, le=1.0)
|
|
49
|
+
stream: bool = False
|
|
50
|
+
stop: Optional[List[str]] = None
|
|
51
|
+
#: Namespaced extension; see the module docstring on why it is validated
|
|
52
|
+
#: rather than honoured per request.
|
|
53
|
+
brainpatch: Optional[Dict[str, float]] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CompletionRequest(BaseModel):
|
|
57
|
+
prompt: str
|
|
58
|
+
model: Optional[str] = None
|
|
59
|
+
max_tokens: Optional[int] = Field(default=None, ge=1, le=MAX_REQUEST_TOKENS)
|
|
60
|
+
temperature: float = Field(default=0.0, ge=0.0, le=2.0)
|
|
61
|
+
top_p: float = Field(default=1.0, gt=0.0, le=1.0)
|
|
62
|
+
stream: bool = False
|
|
63
|
+
brainpatch: Optional[Dict[str, float]] = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_app(model: Any, served_model_name: Optional[str] = None) -> Any:
|
|
67
|
+
"""Build the FastAPI application around an already-loaded model."""
|
|
68
|
+
try:
|
|
69
|
+
from fastapi import FastAPI, HTTPException
|
|
70
|
+
from fastapi.responses import StreamingResponse
|
|
71
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
72
|
+
raise ModuleNotFoundError(
|
|
73
|
+
"the server needs FastAPI -- pip install 'brainpatch[server]'"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
descriptor = model.backend.describe_model()
|
|
77
|
+
model_name = served_model_name or descriptor.model_id
|
|
78
|
+
capabilities = model.capabilities()
|
|
79
|
+
|
|
80
|
+
# Freeze patch state for the server's lifetime where the backend supports it.
|
|
81
|
+
begin = getattr(model.backend, "begin_serving", None)
|
|
82
|
+
if callable(begin):
|
|
83
|
+
begin()
|
|
84
|
+
|
|
85
|
+
api = FastAPI(title="BrainPatch", version="1.0")
|
|
86
|
+
|
|
87
|
+
def check_patch_override(requested: Optional[Dict[str, float]]) -> None:
|
|
88
|
+
"""Accept a per-request patch spec only if it matches the server's."""
|
|
89
|
+
if not requested:
|
|
90
|
+
return
|
|
91
|
+
if capabilities.per_request_strength:
|
|
92
|
+
return
|
|
93
|
+
current = {name: model.backend.patches[name].strength for name in model.list_patches()}
|
|
94
|
+
mismatched = {
|
|
95
|
+
name: value
|
|
96
|
+
for name, value in requested.items()
|
|
97
|
+
if abs(current.get(name, 0.0) - float(value)) > 1e-9
|
|
98
|
+
}
|
|
99
|
+
if mismatched:
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status_code=400,
|
|
102
|
+
detail={
|
|
103
|
+
"message": (
|
|
104
|
+
f"the '{model.backend.name}' backend does not support "
|
|
105
|
+
"per-request patch strength; requests share one model, so "
|
|
106
|
+
"honouring this would change other users' output."
|
|
107
|
+
),
|
|
108
|
+
"server_configuration": current,
|
|
109
|
+
"requested": requested,
|
|
110
|
+
"hint": "restart the server with the strengths you want",
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def make_config(
|
|
115
|
+
max_tokens: Optional[int], temperature: float, top_p: float, stop: Optional[List[str]]
|
|
116
|
+
) -> GenerationConfig:
|
|
117
|
+
return GenerationConfig(
|
|
118
|
+
max_new_tokens=min(max_tokens or 256, MAX_REQUEST_TOKENS),
|
|
119
|
+
temperature=temperature,
|
|
120
|
+
top_p=top_p,
|
|
121
|
+
stop=list(stop or []),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def render(messages: List[ChatMessage]) -> tuple:
|
|
125
|
+
system = next((m.content for m in messages if m.role == "system"), None)
|
|
126
|
+
user = next((m.content for m in reversed(messages) if m.role == "user"), None)
|
|
127
|
+
if user is None:
|
|
128
|
+
raise HTTPException(status_code=400, detail="no user message in request")
|
|
129
|
+
return user, system
|
|
130
|
+
|
|
131
|
+
@api.get("/health")
|
|
132
|
+
def health() -> Dict[str, Any]:
|
|
133
|
+
return {
|
|
134
|
+
"status": "ok",
|
|
135
|
+
"backend": model.backend.name,
|
|
136
|
+
"model": model_name,
|
|
137
|
+
"patches": {
|
|
138
|
+
name: {
|
|
139
|
+
"strength": model.backend.patches[name].strength,
|
|
140
|
+
"enabled": model.backend.patches[name].enabled,
|
|
141
|
+
"evidence_level": model.backend.patches[name].manifest.evidence_level,
|
|
142
|
+
}
|
|
143
|
+
for name in model.list_patches()
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@api.get("/v1/models")
|
|
148
|
+
def list_models() -> Dict[str, Any]:
|
|
149
|
+
return {
|
|
150
|
+
"object": "list",
|
|
151
|
+
"data": [
|
|
152
|
+
{
|
|
153
|
+
"id": model_name,
|
|
154
|
+
"object": "model",
|
|
155
|
+
"created": int(time.time()),
|
|
156
|
+
"owned_by": "brainpatch",
|
|
157
|
+
"brainpatch": {"patches": model.list_patches()},
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@api.post("/v1/chat/completions")
|
|
163
|
+
def chat_completions(request: ChatRequest) -> Any:
|
|
164
|
+
check_patch_override(request.brainpatch)
|
|
165
|
+
prompt, system = render(request.messages)
|
|
166
|
+
cfg = make_config(request.max_tokens, request.temperature, request.top_p, request.stop)
|
|
167
|
+
|
|
168
|
+
if request.stream:
|
|
169
|
+
return StreamingResponse(
|
|
170
|
+
stream_chat(prompt, cfg, system, model_name), media_type="text/event-stream"
|
|
171
|
+
)
|
|
172
|
+
text = model.generate(prompt, cfg, system=system)
|
|
173
|
+
return chat_response(
|
|
174
|
+
text,
|
|
175
|
+
model_name,
|
|
176
|
+
prompt_tokens=count_tokens(model.backend, prompt),
|
|
177
|
+
completion_tokens=count_tokens(model.backend, text),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@api.post("/v1/completions")
|
|
181
|
+
def completions(request: CompletionRequest) -> Dict[str, Any]:
|
|
182
|
+
check_patch_override(request.brainpatch)
|
|
183
|
+
cfg = make_config(request.max_tokens, request.temperature, request.top_p, None)
|
|
184
|
+
text = model.generate(request.prompt, cfg, use_chat_template=False)
|
|
185
|
+
return {
|
|
186
|
+
"id": "cmpl-" + uuid.uuid4().hex[:24],
|
|
187
|
+
"object": "text_completion",
|
|
188
|
+
"created": int(time.time()),
|
|
189
|
+
"model": model_name,
|
|
190
|
+
"choices": [{"text": text, "index": 0, "finish_reason": "stop", "logprobs": None}],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def stream_chat(prompt: str, cfg: GenerationConfig, system: Optional[str], name: str):
|
|
194
|
+
import json as _json
|
|
195
|
+
|
|
196
|
+
request_id = "chatcmpl-" + uuid.uuid4().hex[:24]
|
|
197
|
+
created = int(time.time())
|
|
198
|
+
|
|
199
|
+
def chunk(delta: Dict[str, Any], finish: Optional[str]) -> str:
|
|
200
|
+
payload = {
|
|
201
|
+
"id": request_id,
|
|
202
|
+
"object": "chat.completion.chunk",
|
|
203
|
+
"created": created,
|
|
204
|
+
"model": name,
|
|
205
|
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
|
206
|
+
}
|
|
207
|
+
return "data: " + _json.dumps(payload) + "\n\n"
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
for piece in model.stream(prompt, cfg, system=system):
|
|
211
|
+
yield chunk({"content": piece}, None)
|
|
212
|
+
except NotImplementedError:
|
|
213
|
+
# Backends without streaming still serve a valid SSE response.
|
|
214
|
+
yield chunk({"content": model.generate(prompt, cfg, system=system)}, None)
|
|
215
|
+
yield chunk({}, "stop")
|
|
216
|
+
yield "data: [DONE]\n\n"
|
|
217
|
+
|
|
218
|
+
return api
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def count_tokens(backend: Any, text: str) -> int:
|
|
222
|
+
"""Token count for ``text``, or 0 if the backend cannot tokenise.
|
|
223
|
+
|
|
224
|
+
Reported zeros were a real problem, not a cosmetic one: any client that
|
|
225
|
+
meters usage, or any benchmark that normalises by generated tokens, silently
|
|
226
|
+
reads "nothing happened". Falling back to 0 is still possible for backends
|
|
227
|
+
with no tokenizer, but a backend that has one now reports the truth.
|
|
228
|
+
"""
|
|
229
|
+
tokenizer = getattr(backend, "tokenizer", None)
|
|
230
|
+
if tokenizer is None:
|
|
231
|
+
# vLLM keeps its tokenizer behind the engine handle rather than exposing
|
|
232
|
+
# an attribute, so a plain getattr finds nothing and every count would
|
|
233
|
+
# come back 0 on the backend most likely to be metered.
|
|
234
|
+
engine = getattr(backend, "llm", None)
|
|
235
|
+
getter = getattr(engine, "get_tokenizer", None)
|
|
236
|
+
if callable(getter):
|
|
237
|
+
try:
|
|
238
|
+
tokenizer = getter()
|
|
239
|
+
except Exception:
|
|
240
|
+
tokenizer = None
|
|
241
|
+
if tokenizer is None:
|
|
242
|
+
return 0
|
|
243
|
+
try:
|
|
244
|
+
return len(tokenizer(text, add_special_tokens=False).input_ids)
|
|
245
|
+
except Exception:
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def chat_response(
|
|
250
|
+
text: str, model_name: str, *, prompt_tokens: int = 0, completion_tokens: int = 0
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
return {
|
|
253
|
+
"id": "chatcmpl-" + uuid.uuid4().hex[:24],
|
|
254
|
+
"object": "chat.completion",
|
|
255
|
+
"created": int(time.time()),
|
|
256
|
+
"model": model_name,
|
|
257
|
+
"choices": [
|
|
258
|
+
{
|
|
259
|
+
"index": 0,
|
|
260
|
+
"message": {"role": "assistant", "content": text},
|
|
261
|
+
"finish_reason": "stop",
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
"usage": {
|
|
265
|
+
"prompt_tokens": prompt_tokens,
|
|
266
|
+
"completion_tokens": completion_tokens,
|
|
267
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
268
|
+
},
|
|
269
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Steering control logic.
|
|
2
|
+
|
|
3
|
+
Pure-Python scheduling and intervention *planning* live here. The torch hooks
|
|
4
|
+
that actually mutate activations live in :mod:`brainpatch.research.ml.intervention`.
|
|
5
|
+
|
|
6
|
+
Keeping the two apart means the trickiest part to get right -- when a strength
|
|
7
|
+
changes and by how much -- is unit-testable on a laptop with no GPU.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from brainpatch.steering.plan import InterventionPlan, PlannedEdit
|
|
11
|
+
from brainpatch.steering.schedule import StrengthSchedule
|
|
12
|
+
|
|
13
|
+
__all__ = ["InterventionPlan", "PlannedEdit", "StrengthSchedule"]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Resolving installed patches into a concrete per-token intervention.
|
|
2
|
+
|
|
3
|
+
The runtime keeps a set of installed :class:`~brainpatch.schemas.patch.BrainPatchSpec`
|
|
4
|
+
objects, each with a user-controllable strength multiplier and enabled flag. On
|
|
5
|
+
every forward pass the hook needs one question answered: *at generated-token
|
|
6
|
+
index n, which feature directions do I add, and with what coefficients?*
|
|
7
|
+
|
|
8
|
+
:class:`InterventionPlan` answers that with no torch involved, which is what
|
|
9
|
+
makes the semantics testable locally. In particular the guarantee that
|
|
10
|
+
|
|
11
|
+
total strength == 0 => no tensor is modified at all
|
|
12
|
+
|
|
13
|
+
is enforced here by returning an empty edit list, so "strength 0" is baseline by
|
|
14
|
+
construction rather than by floating-point luck.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from brainpatch.schemas.patch import BrainPatchSpec
|
|
22
|
+
from brainpatch.steering.schedule import StrengthSchedule
|
|
23
|
+
|
|
24
|
+
#: Coefficients whose absolute value is below this are treated as exactly zero.
|
|
25
|
+
#: Chosen well below any strength a user would set, but above float noise.
|
|
26
|
+
STRENGTH_EPSILON = 1e-12
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PlannedEdit:
|
|
31
|
+
"""One feature direction to apply, with its resolved coefficient."""
|
|
32
|
+
|
|
33
|
+
feature_id: int
|
|
34
|
+
coefficient: float
|
|
35
|
+
mode: str = "add"
|
|
36
|
+
source_patch: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class InstalledPatch:
|
|
41
|
+
"""A patch registered in the runtime, plus its live control state."""
|
|
42
|
+
|
|
43
|
+
spec: BrainPatchSpec
|
|
44
|
+
strength: float = 1.0
|
|
45
|
+
enabled: bool = True
|
|
46
|
+
schedule: StrengthSchedule | None = None
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if self.schedule is None and self.spec.schedule is not None:
|
|
50
|
+
self.schedule = StrengthSchedule.from_dict(self.spec.schedule)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
return self.spec.name
|
|
55
|
+
|
|
56
|
+
def multiplier_at(self, token_index: int) -> float:
|
|
57
|
+
"""Global multiplier for this patch at a generated-token index."""
|
|
58
|
+
if not self.enabled:
|
|
59
|
+
return 0.0
|
|
60
|
+
if self.schedule is None:
|
|
61
|
+
return self.strength
|
|
62
|
+
return self.strength * self.schedule.strength_at(token_index)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class InterventionPlan:
|
|
67
|
+
"""The set of installed patches and the layers they touch.
|
|
68
|
+
|
|
69
|
+
A plan is intentionally cheap to build and query: the hot path
|
|
70
|
+
(:meth:`edits_at`) runs once per forward pass.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
patches: dict[str, InstalledPatch] = field(default_factory=dict)
|
|
74
|
+
#: Restrict interventions to ``[start, end)`` in generated-token index.
|
|
75
|
+
#: ``None`` means "every generated token".
|
|
76
|
+
token_range: tuple[int, int] | None = None
|
|
77
|
+
|
|
78
|
+
# -- registry --------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def install(
|
|
81
|
+
self,
|
|
82
|
+
spec: BrainPatchSpec,
|
|
83
|
+
*,
|
|
84
|
+
strength: float = 1.0,
|
|
85
|
+
enabled: bool = True,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Register a patch. Re-installing the same name replaces it."""
|
|
88
|
+
self.patches[spec.name] = InstalledPatch(spec=spec, strength=strength, enabled=enabled)
|
|
89
|
+
|
|
90
|
+
def uninstall(self, name: str) -> None:
|
|
91
|
+
if name not in self.patches:
|
|
92
|
+
raise KeyError(f"no patch named {name!r} is installed")
|
|
93
|
+
del self.patches[name]
|
|
94
|
+
|
|
95
|
+
def set_strength(self, name: str, strength: float) -> None:
|
|
96
|
+
if name not in self.patches:
|
|
97
|
+
raise KeyError(f"no patch named {name!r} is installed")
|
|
98
|
+
self.patches[name].strength = float(strength)
|
|
99
|
+
|
|
100
|
+
def set_enabled(self, name: str, enabled: bool) -> None:
|
|
101
|
+
if name not in self.patches:
|
|
102
|
+
raise KeyError(f"no patch named {name!r} is installed")
|
|
103
|
+
self.patches[name].enabled = bool(enabled)
|
|
104
|
+
|
|
105
|
+
def set_schedule(self, name: str, schedule: StrengthSchedule | None) -> None:
|
|
106
|
+
if name not in self.patches:
|
|
107
|
+
raise KeyError(f"no patch named {name!r} is installed")
|
|
108
|
+
self.patches[name].schedule = schedule
|
|
109
|
+
|
|
110
|
+
# -- queries ---------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def layers(self) -> list[int]:
|
|
113
|
+
"""Every layer index that at least one installed patch hooks."""
|
|
114
|
+
return sorted({p.spec.sae.layer for p in self.patches.values()})
|
|
115
|
+
|
|
116
|
+
def is_active(self, token_index: int) -> bool:
|
|
117
|
+
"""True if any edit would be applied at this generated-token index."""
|
|
118
|
+
return bool(self.edits_at(token_index))
|
|
119
|
+
|
|
120
|
+
def in_token_range(self, token_index: int) -> bool:
|
|
121
|
+
if self.token_range is None:
|
|
122
|
+
return True
|
|
123
|
+
start, end = self.token_range
|
|
124
|
+
return start <= token_index < end
|
|
125
|
+
|
|
126
|
+
def edits_at(self, token_index: int, *, layer: int | None = None) -> list[PlannedEdit]:
|
|
127
|
+
"""Resolve the edits to apply at ``token_index``.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
token_index:
|
|
132
|
+
Index of the token being generated, 0-based, prompt excluded.
|
|
133
|
+
layer:
|
|
134
|
+
When given, only edits for patches hooking this layer are returned.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
list[PlannedEdit]
|
|
139
|
+
Possibly empty. An empty list is the signal to leave the residual
|
|
140
|
+
stream untouched, which is what makes ``strength=0`` bit-identical
|
|
141
|
+
to baseline rather than merely close to it.
|
|
142
|
+
"""
|
|
143
|
+
if not self.in_token_range(token_index):
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
edits: list[PlannedEdit] = []
|
|
147
|
+
for patch in self.patches.values():
|
|
148
|
+
if layer is not None and patch.spec.sae.layer != layer:
|
|
149
|
+
continue
|
|
150
|
+
multiplier = patch.multiplier_at(token_index)
|
|
151
|
+
if abs(multiplier) < STRENGTH_EPSILON:
|
|
152
|
+
continue
|
|
153
|
+
for edit in patch.spec.features:
|
|
154
|
+
coefficient = multiplier * edit.strength
|
|
155
|
+
if abs(coefficient) < STRENGTH_EPSILON:
|
|
156
|
+
continue
|
|
157
|
+
edits.append(
|
|
158
|
+
PlannedEdit(
|
|
159
|
+
feature_id=edit.feature_id,
|
|
160
|
+
coefficient=coefficient,
|
|
161
|
+
mode=edit.mode,
|
|
162
|
+
source_patch=patch.name,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return edits
|
|
166
|
+
|
|
167
|
+
def describe(self) -> list[str]:
|
|
168
|
+
"""One human-readable line per installed patch."""
|
|
169
|
+
lines = []
|
|
170
|
+
for patch in self.patches.values():
|
|
171
|
+
state = "on" if patch.enabled else "off"
|
|
172
|
+
sched = "scheduled" if patch.schedule is not None else "constant"
|
|
173
|
+
lines.append(
|
|
174
|
+
f"{patch.name}: strength={patch.strength:+.3f} [{state}, {sched}] "
|
|
175
|
+
f"-> {patch.spec.summary()}"
|
|
176
|
+
)
|
|
177
|
+
return lines
|