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
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
|