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,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}")
@@ -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