brainpatch 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. brainpatch/__init__.py +92 -0
  2. brainpatch/backends/__init__.py +19 -0
  3. brainpatch/backends/llamacpp.py +383 -0
  4. brainpatch/backends/mlx_backend.py +213 -0
  5. brainpatch/backends/transformers_backend.py +473 -0
  6. brainpatch/backends/vllm_backend.py +299 -0
  7. brainpatch/backends/vllm_worker.py +129 -0
  8. brainpatch/cli.py +825 -0
  9. brainpatch/config.py +245 -0
  10. brainpatch/datasets/__init__.py +20 -0
  11. brainpatch/datasets/contrast_sets.py +64 -0
  12. brainpatch/evaluation/__init__.py +28 -0
  13. brainpatch/evaluation/metrics.py +223 -0
  14. brainpatch/patch/__init__.py +64 -0
  15. brainpatch/patch/compiler.py +324 -0
  16. brainpatch/patch/format.py +489 -0
  17. brainpatch/patch/loader.py +312 -0
  18. brainpatch/patch/registry.py +300 -0
  19. brainpatch/patch/tensors.py +236 -0
  20. brainpatch/patch/validation.py +157 -0
  21. brainpatch/paths.py +184 -0
  22. brainpatch/py.typed +0 -0
  23. brainpatch/research/__init__.py +16 -0
  24. brainpatch/research/antisycophancy.py +348 -0
  25. brainpatch/research/behaviour_eval.py +711 -0
  26. brainpatch/research/generation_eval.py +346 -0
  27. brainpatch/research/ml/__init__.py +35 -0
  28. brainpatch/research/ml/activation_store.py +232 -0
  29. brainpatch/research/ml/causal.py +386 -0
  30. brainpatch/research/ml/corpus.py +165 -0
  31. brainpatch/research/ml/evaluation.py +188 -0
  32. brainpatch/research/ml/extraction.py +464 -0
  33. brainpatch/research/ml/feature_analysis.py +317 -0
  34. brainpatch/research/ml/generation.py +109 -0
  35. brainpatch/research/ml/hooks.py +183 -0
  36. brainpatch/research/ml/intervention.py +274 -0
  37. brainpatch/research/ml/model.py +219 -0
  38. brainpatch/research/ml/patch_search.py +337 -0
  39. brainpatch/research/ml/runtime.py +343 -0
  40. brainpatch/research/ml/sae.py +383 -0
  41. brainpatch/research/ml/training.py +376 -0
  42. brainpatch/research/stance_rubric.py +170 -0
  43. brainpatch/research/sycophancy_data.py +982 -0
  44. brainpatch/research/sycophancy_data_r1.py +1701 -0
  45. brainpatch/research/sycophancy_data_v2.py +1649 -0
  46. brainpatch/research/sycophancy_data_v3.py +2288 -0
  47. brainpatch/research/sycophancy_v2_build.py +362 -0
  48. brainpatch/research/sycophancy_v3_build.py +188 -0
  49. brainpatch/research/utility_probe.py +139 -0
  50. brainpatch/runtime/__init__.py +50 -0
  51. brainpatch/runtime/auto.py +157 -0
  52. brainpatch/runtime/base.py +311 -0
  53. brainpatch/runtime/capabilities.py +96 -0
  54. brainpatch/runtime/model.py +260 -0
  55. brainpatch/runtime/scheduling.py +13 -0
  56. brainpatch/schemas/__init__.py +35 -0
  57. brainpatch/schemas/contrast.py +161 -0
  58. brainpatch/schemas/feature.py +193 -0
  59. brainpatch/schemas/manifest.py +167 -0
  60. brainpatch/schemas/patch.py +379 -0
  61. brainpatch/schemas/patch_io.py +88 -0
  62. brainpatch/schemas/sae.py +146 -0
  63. brainpatch/server/__init__.py +11 -0
  64. brainpatch/server/app.py +269 -0
  65. brainpatch/steering/__init__.py +13 -0
  66. brainpatch/steering/plan.py +177 -0
  67. brainpatch/steering/schedule.py +138 -0
  68. brainpatch/ui/__init__.py +11 -0
  69. brainpatch/ui/app.py +201 -0
  70. brainpatch/verify/__init__.py +66 -0
  71. brainpatch/verify/behavioural.py +156 -0
  72. brainpatch/verify/checks.py +204 -0
  73. brainpatch/verify/corruptions.py +335 -0
  74. brainpatch/verify/report.py +133 -0
  75. brainpatch/verify/vectors.py +95 -0
  76. brainpatch/verify/workflow.py +331 -0
  77. brainpatch-1.2.0.dist-info/METADATA +556 -0
  78. brainpatch-1.2.0.dist-info/RECORD +82 -0
  79. brainpatch-1.2.0.dist-info/WHEEL +5 -0
  80. brainpatch-1.2.0.dist-info/entry_points.txt +2 -0
  81. brainpatch-1.2.0.dist-info/licenses/LICENSE +190 -0
  82. brainpatch-1.2.0.dist-info/top_level.txt +1 -0
brainpatch/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """BrainPatch: tiny, installable activation patches for frozen language models.
2
+
3
+ from brainpatch import BrainPatchedModel
4
+
5
+ model = BrainPatchedModel.from_pretrained("Qwen/Qwen2.5-1.5B-Instruct")
6
+ model.install("09Catho/example-patch")
7
+ print(model.generate("Evaluate my idea."))
8
+
9
+ Import policy
10
+ -------------
11
+ ``import brainpatch`` works on a bare Python 3.10+ with no ML stack: the patch
12
+ format, registry, validation and CLI are pure standard library plus a couple of
13
+ small pure-Python dependencies.
14
+
15
+ Anything that needs torch, transformers, vLLM or llama.cpp is imported lazily
16
+ when a backend is actually instantiated. :class:`BrainPatchedModel` is exposed
17
+ through ``__getattr__`` for the same reason -- naming it here must not drag the
18
+ runtime's dependencies into a process that only wants to inspect a patch file.
19
+
20
+ Research tooling lives under :mod:`brainpatch.research` and is never imported by
21
+ the runtime.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ __version__ = "1.1.0"
29
+
30
+ from brainpatch.patch.format import (
31
+ FORMAT_VERSION,
32
+ SUFFIX,
33
+ BaseModelSpec,
34
+ Intervention,
35
+ Manifest,
36
+ PatchFormatError,
37
+ )
38
+ from brainpatch.patch.loader import LoadedPatch, load_patch, save_patch
39
+ from brainpatch.patch.registry import PatchRegistry, default_registry
40
+ from brainpatch.patch.validation import PatchCompatibilityError
41
+ from brainpatch.steering.schedule import StrengthSchedule
42
+
43
+ if TYPE_CHECKING: # pragma: no cover - typing only
44
+ from brainpatch.runtime.base import GenerationConfig
45
+ from brainpatch.runtime.model import BrainPatchedModel, PatchHandle
46
+
47
+ #: Symbols resolved lazily so the top-level import stays light.
48
+ _LAZY: dict[str, tuple[str, str]] = {
49
+ "BrainPatchedModel": ("brainpatch.runtime.model", "BrainPatchedModel"),
50
+ "PatchHandle": ("brainpatch.runtime.model", "PatchHandle"),
51
+ "GenerationConfig": ("brainpatch.runtime.base", "GenerationConfig"),
52
+ "Capabilities": ("brainpatch.runtime.capabilities", "Capabilities"),
53
+ "available_backends": ("brainpatch.runtime.auto", "available_backends"),
54
+ "environment_report": ("brainpatch.runtime.auto", "environment_report"),
55
+ }
56
+
57
+ __all__ = [
58
+ "BaseModelSpec",
59
+ "BrainPatchedModel",
60
+ "Capabilities",
61
+ "FORMAT_VERSION",
62
+ "GenerationConfig",
63
+ "Intervention",
64
+ "LoadedPatch",
65
+ "Manifest",
66
+ "PatchCompatibilityError",
67
+ "PatchFormatError",
68
+ "PatchHandle",
69
+ "PatchRegistry",
70
+ "SUFFIX",
71
+ "StrengthSchedule",
72
+ "__version__",
73
+ "available_backends",
74
+ "default_registry",
75
+ "environment_report",
76
+ "load_patch",
77
+ "save_patch",
78
+ ]
79
+
80
+
81
+ def __getattr__(name: str) -> Any:
82
+ target = _LAZY.get(name)
83
+ if target is None:
84
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
85
+ import importlib
86
+
87
+ module, attribute = target
88
+ return getattr(importlib.import_module(module), attribute)
89
+
90
+
91
+ def __dir__() -> list[str]:
92
+ return sorted(__all__)
@@ -0,0 +1,19 @@
1
+ """Inference-engine adapters.
2
+
3
+ Each module here implements :class:`~brainpatch.runtime.base.BrainPatchBackend`
4
+ for one engine and imports that engine's dependencies **only when
5
+ instantiated**. ``is_available()`` and ``capabilities()`` are classmethods that
6
+ work with nothing installed, which is what lets ``brainpatch doctor`` report on
7
+ a bare machine.
8
+
9
+ Nothing is imported eagerly here: importing this package must not pull in torch.
10
+
11
+ ================= ==============================================
12
+ ``transformers`` reference backend; PyTorch, CUDA/CPU/MPS
13
+ ``llamacpp`` GGUF via upstream llama.cpp control vectors
14
+ ``vllm`` high-throughput serving with request isolation
15
+ ``mlx`` Apple Silicon via MLX-LM
16
+ ================= ==============================================
17
+ """
18
+
19
+ __all__: list[str] = []
@@ -0,0 +1,383 @@
1
+ """llama.cpp backend, via upstream control vectors.
2
+
3
+ Approach
4
+ --------
5
+ llama.cpp already implements exactly the operation a BrainPatch performs: add a
6
+ per-layer direction to the residual stream. It exposes this as *control
7
+ vectors*, with CLI flags on ``llama-cli`` and ``llama-server``::
8
+
9
+ --control-vector-scaled FNAME:SCALE
10
+ --control-vector-layer-range START END
11
+
12
+ So this backend does **not** fork llama.cpp or reimplement inference. It
13
+ compiles the patch to a control-vector GGUF (see
14
+ :func:`brainpatch.patch.compiler.export_llamacpp_control_vector`) and drives the
15
+ upstream binary. Upstream stays upstream; BrainPatch supplies the vector.
16
+
17
+ Three details that are easy to get silently wrong
18
+ --------------------------------------------------
19
+ **Argument format.** ``--control-vector-scaled`` takes ONE ``FNAME:SCALE``
20
+ token, not two arguments. Passing them separately fails with
21
+ ``control-vector-scaled format: FNAME:SCALE``.
22
+
23
+ **Process termination.** ``-no-cnv`` alone is not enough on b10344: llama-cli
24
+ stays in interactive mode and prints ``> `` forever against EOF stdin, which
25
+ looks exactly like a hang. ``-st/--single-turn`` is what makes it exit.
26
+
27
+ **Layer indexing.** BrainPatch layers are 0-based decoder blocks. llama.cpp
28
+ control-vector tensors are named ``direction.N`` with **1-based** N, and
29
+ ``--control-vector-layer-range`` is also 1-based and inclusive. The exporter
30
+ does the ``+1``; this backend passes the matching range so a single-layer patch
31
+ applies to that layer and not to every layer.
32
+
33
+ **Quantization.** A direction fitted on bf16 activations is not guaranteed to
34
+ behave identically on a Q4 model. Nothing here assumes it does, and the
35
+ capability table reports only quantizations that were actually exercised.
36
+
37
+ Known capability gap
38
+ --------------------
39
+ Token-level schedules are not supported. A control vector is bound for the whole
40
+ run; changing it between decode steps would need a persistent libllama process
41
+ with per-step control, which the CLI does not expose. This is reported as
42
+ ``dynamic_schedule=False`` rather than emulated badly.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import os
49
+ import shutil
50
+ import subprocess
51
+ import tempfile
52
+ from pathlib import Path
53
+ from typing import Any, Iterator
54
+
55
+ from brainpatch.patch.validation import ModelDescriptor
56
+ from brainpatch.runtime.base import BrainPatchBackend, GenerationConfig
57
+ from brainpatch.runtime.capabilities import Capabilities
58
+
59
+ #: Binaries searched on PATH, plus ``BRAINPATCH_LLAMACPP_BIN`` if set.
60
+ CLI_NAMES = ("llama-cli", "llama")
61
+ SERVER_NAMES = ("llama-server",)
62
+ ENV_BIN = "BRAINPATCH_LLAMACPP_BIN"
63
+
64
+
65
+ def _find_binary(names: tuple[str, ...]) -> str | None:
66
+ override = os.environ.get(ENV_BIN)
67
+ if override:
68
+ candidate = Path(override)
69
+ if candidate.is_file():
70
+ return str(candidate)
71
+ if candidate.is_dir():
72
+ for name in names:
73
+ found = candidate / name
74
+ if found.is_file():
75
+ return str(found)
76
+ for name in names:
77
+ found = shutil.which(name)
78
+ if found:
79
+ return found
80
+ return None
81
+
82
+
83
+ class LlamaCppBackend(BrainPatchBackend):
84
+ """Run GGUF models through upstream llama.cpp with a compiled control vector."""
85
+
86
+ name = "llamacpp"
87
+
88
+ def __init__(self) -> None:
89
+ super().__init__()
90
+ self.model_path: Path | None = None
91
+ self.binary: str | None = None
92
+ self.n_gpu_layers: int = 0
93
+ self.extra_args: list[str] = []
94
+ self._gguf_meta: dict[str, Any] = {}
95
+ self._vector_files: dict[str, Path] = {}
96
+ self._tmpdir: tempfile.TemporaryDirectory | None = None
97
+
98
+ # -- availability ----------------------------------------------------------
99
+
100
+ @classmethod
101
+ def is_available(cls) -> tuple[bool, str]:
102
+ binary = _find_binary(CLI_NAMES)
103
+ if binary is None:
104
+ return False, (
105
+ "llama-cli not found on PATH -- install llama.cpp, or set "
106
+ f"{ENV_BIN} to its binary or bin directory"
107
+ )
108
+ try:
109
+ out = subprocess.run(
110
+ [binary, "--version"], capture_output=True, text=True, timeout=20
111
+ )
112
+ version = (out.stderr or out.stdout).strip().splitlines()
113
+ detail = version[0] if version else "unknown version"
114
+ except (OSError, subprocess.SubprocessError) as exc:
115
+ return False, f"llama-cli found at {binary} but could not run: {exc}"
116
+ return True, f"{binary} ({detail})"
117
+
118
+ @classmethod
119
+ def capabilities(cls) -> Capabilities:
120
+ return Capabilities(
121
+ name=cls.name,
122
+ static_intervention=True,
123
+ dynamic_schedule=False,
124
+ multiple_patches=True,
125
+ streaming=True,
126
+ cpu=True,
127
+ cuda=True,
128
+ apple_silicon=True,
129
+ server=True,
130
+ concurrent_requests=True,
131
+ per_request_strength=False,
132
+ quantization=(),
133
+ notes={
134
+ "dynamic_schedule": (
135
+ "A control vector is bound for the whole run; llama.cpp's CLI "
136
+ "exposes no per-decode-step control. Use the transformers "
137
+ "backend for token-level schedules."
138
+ ),
139
+ "per_request_strength": (
140
+ "Control-vector scale is fixed at process start, so it is "
141
+ "server-wide rather than per request."
142
+ ),
143
+ "quantization": (
144
+ "GGUF quantizations load, but a direction fitted on bf16 is not "
145
+ "guaranteed to behave identically on Q4. Verified quantizations "
146
+ "are listed per patch in its compatibility block."
147
+ ),
148
+ },
149
+ )
150
+
151
+ # -- model -----------------------------------------------------------------
152
+
153
+ def load_model(
154
+ self,
155
+ model: str,
156
+ *,
157
+ n_gpu_layers: int = 0,
158
+ extra_args: list[str] | None = None,
159
+ **kwargs: Any,
160
+ ) -> None:
161
+ """Register a GGUF path. llama.cpp loads per invocation, not here."""
162
+ path = Path(model).expanduser()
163
+ if not path.is_file():
164
+ raise FileNotFoundError(f"GGUF model not found: {path}")
165
+ binary = _find_binary(CLI_NAMES)
166
+ if binary is None:
167
+ raise RuntimeError("llama-cli is not available; run `brainpatch doctor`")
168
+
169
+ self.model_path = path
170
+ self.binary = binary
171
+ self.n_gpu_layers = n_gpu_layers
172
+ self.extra_args = list(extra_args or [])
173
+ self._gguf_meta = read_gguf_metadata(path)
174
+ self._tmpdir = tempfile.TemporaryDirectory(prefix="brainpatch-cv-")
175
+
176
+ def describe_model(self) -> ModelDescriptor:
177
+ if self.model_path is None:
178
+ raise RuntimeError("no model loaded; call load_model() first")
179
+ meta = self._gguf_meta
180
+ return ModelDescriptor(
181
+ model_id=meta.get("model_id") or self.model_path.stem,
182
+ hidden_size=int(meta.get("hidden_size", 0)),
183
+ num_layers=int(meta.get("num_layers", 0)),
184
+ architecture=str(meta.get("architecture", "")),
185
+ revision=None,
186
+ )
187
+
188
+ # -- control vectors -------------------------------------------------------
189
+
190
+ def _on_patches_changed(self) -> None:
191
+ self._vector_files.clear()
192
+
193
+ def _control_vector_args(self) -> list[str]:
194
+ """Build ``--control-vector-scaled`` args for every enabled patch."""
195
+ from brainpatch.patch.compiler import export_llamacpp_control_vector
196
+
197
+ if self._tmpdir is None:
198
+ raise RuntimeError("no model loaded")
199
+
200
+ args: list[str] = []
201
+ layers: set[int] = set()
202
+ for name, active in self.patches.items():
203
+ multiplier = active.multiplier_at(0)
204
+ if multiplier == 0.0:
205
+ continue # disabled or zeroed: emit nothing at all
206
+ path = self._vector_files.get(name)
207
+ if path is None:
208
+ if active.patch.source is None:
209
+ raise RuntimeError(
210
+ f"patch {name!r} has no source file; llama.cpp export needs one"
211
+ )
212
+ path = Path(self._tmpdir.name) / f"{name}.gguf"
213
+ export_llamacpp_control_vector(active.patch.source, path, strength=1.0)
214
+ self._vector_files[name] = path
215
+ # Upstream expects a single FNAME:SCALE token, not two arguments.
216
+ # Passing them separately fails with
217
+ # `control-vector-scaled format: FNAME:SCALE`.
218
+ args += ["--control-vector-scaled", f"{path}:{multiplier:.6f}"]
219
+ layers.update(active.manifest.layers)
220
+
221
+ if layers:
222
+ # 1-based inclusive, matching the exporter's direction.N naming.
223
+ args += [
224
+ "--control-vector-layer-range",
225
+ str(min(layers) + 1),
226
+ str(max(layers) + 1),
227
+ ]
228
+ return args
229
+
230
+ # -- generation ------------------------------------------------------------
231
+
232
+ def _build_command(self, prompt: str, cfg: GenerationConfig) -> list[str]:
233
+ assert self.binary and self.model_path
234
+ cmd = [
235
+ self.binary,
236
+ "-m", str(self.model_path),
237
+ "-p", prompt,
238
+ "-n", str(cfg.max_new_tokens),
239
+ "-ngl", str(self.n_gpu_layers),
240
+ "--no-display-prompt",
241
+ # Both are needed. `-no-cnv` asks for non-conversation mode, but on
242
+ # llama.cpp b10344 only `-st/--single-turn` reliably makes the
243
+ # process exit after one turn; without it llama-cli sits in
244
+ # interactive mode emitting "> " until it is killed.
245
+ "-no-cnv",
246
+ "-st",
247
+ ]
248
+ if cfg.temperature <= 0:
249
+ cmd += ["--temp", "0"]
250
+ else:
251
+ cmd += ["--temp", str(cfg.temperature), "--top-p", str(cfg.top_p), "-s", str(cfg.seed)]
252
+ if cfg.top_k > 0:
253
+ cmd += ["--top-k", str(cfg.top_k)]
254
+ cmd += self._control_vector_args()
255
+ cmd += self.extra_args
256
+ return cmd
257
+
258
+ def generate(
259
+ self, prompt: str, config: GenerationConfig | None = None, *, timeout: int = 600, **kwargs: Any
260
+ ) -> str:
261
+ if self.model_path is None:
262
+ raise RuntimeError("no model loaded; call load_model() first")
263
+ cfg = config or GenerationConfig()
264
+ cmd = self._build_command(prompt, cfg)
265
+ try:
266
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
267
+ except subprocess.TimeoutExpired as exc:
268
+ raise RuntimeError(f"llama.cpp timed out after {timeout}s") from exc
269
+ if out.returncode != 0:
270
+ raise RuntimeError(
271
+ f"llama-cli exited {out.returncode}:\n{out.stderr[-2000:]}"
272
+ )
273
+ return out.stdout.strip()
274
+
275
+ def stream(
276
+ self, prompt: str, config: GenerationConfig | None = None, **kwargs: Any
277
+ ) -> Iterator[str]:
278
+ if self.model_path is None:
279
+ raise RuntimeError("no model loaded; call load_model() first")
280
+ cfg = config or GenerationConfig()
281
+ cmd = self._build_command(prompt, cfg)
282
+ process = subprocess.Popen(
283
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1
284
+ )
285
+ try:
286
+ assert process.stdout is not None
287
+ for line in process.stdout:
288
+ yield line
289
+ finally:
290
+ process.terminate()
291
+ process.wait(timeout=10)
292
+
293
+ def server_command(self, *, port: int = 8000, host: str = "127.0.0.1") -> list[str]:
294
+ """Full ``llama-server`` command line, control vectors included."""
295
+ binary = _find_binary(SERVER_NAMES)
296
+ if binary is None:
297
+ raise RuntimeError("llama-server not found on PATH")
298
+ if self.model_path is None:
299
+ raise RuntimeError("no model loaded")
300
+ return [
301
+ binary,
302
+ "-m", str(self.model_path),
303
+ "--host", host,
304
+ "--port", str(port),
305
+ "-ngl", str(self.n_gpu_layers),
306
+ *self._control_vector_args(),
307
+ *self.extra_args,
308
+ ]
309
+
310
+ def unload(self) -> None:
311
+ if self._tmpdir is not None:
312
+ self._tmpdir.cleanup()
313
+ self._tmpdir = None
314
+ self._vector_files.clear()
315
+
316
+
317
+ def read_gguf_metadata(path: str | os.PathLike[str]) -> dict[str, Any]:
318
+ """Read architecture facts from a GGUF header.
319
+
320
+ Uses the ``gguf`` package when present. Returns ``{}`` rather than raising
321
+ when it is not, so a missing optional dependency degrades to "unknown model
322
+ geometry" instead of blocking the backend.
323
+ """
324
+ try:
325
+ import gguf
326
+ except ModuleNotFoundError:
327
+ return {}
328
+
329
+ try:
330
+ reader = gguf.GGUFReader(str(path), "r")
331
+ except Exception: # noqa: BLE001 - a malformed GGUF must not crash doctor
332
+ return {}
333
+
334
+ fields = reader.fields
335
+ arch = _gguf_str(fields.get("general.architecture"))
336
+ meta: dict[str, Any] = {
337
+ "architecture": arch or "",
338
+ "model_id": _gguf_str(fields.get("general.name")) or "",
339
+ }
340
+ if arch:
341
+ meta["hidden_size"] = _gguf_int(fields.get(f"{arch}.embedding_length")) or 0
342
+ meta["num_layers"] = _gguf_int(fields.get(f"{arch}.block_count")) or 0
343
+ meta["quantization"] = _gguf_int(fields.get("general.file_type"))
344
+ return meta
345
+
346
+
347
+ def _gguf_str(field: Any) -> str | None:
348
+ if field is None or not getattr(field, "parts", None):
349
+ return None
350
+ try:
351
+ return str(bytes(field.parts[field.data[0]]), encoding="utf-8")
352
+ except Exception: # noqa: BLE001
353
+ return None
354
+
355
+
356
+ def _gguf_int(field: Any) -> int | None:
357
+ if field is None or not getattr(field, "parts", None):
358
+ return None
359
+ try:
360
+ return int(field.parts[field.data[0]][0])
361
+ except Exception: # noqa: BLE001
362
+ return None
363
+
364
+
365
+ def describe_export(patch_path: str | os.PathLike[str]) -> str:
366
+ """Human-readable summary of what an export would produce."""
367
+ from brainpatch.patch.loader import load_patch
368
+
369
+ loaded = load_patch(patch_path)
370
+ layers = loaded.manifest.layers
371
+ return json.dumps(
372
+ {
373
+ "patch": loaded.manifest.name,
374
+ "brainpatch_layers_0based": layers,
375
+ "llamacpp_direction_indices_1based": [layer + 1 for layer in layers],
376
+ "layer_range_flag": ["--control-vector-layer-range", min(layers) + 1, max(layers) + 1],
377
+ "hidden_size": loaded.manifest.base_model.hidden_size,
378
+ },
379
+ indent=2,
380
+ )
381
+
382
+
383
+ BACKEND = LlamaCppBackend