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,138 @@
|
|
|
1
|
+
"""Token-indexed strength schedules for dynamic steering.
|
|
2
|
+
|
|
3
|
+
A schedule maps *generated token index* to a strength multiplier, using
|
|
4
|
+
step-hold semantics: the value at index ``n`` is the value of the largest
|
|
5
|
+
keyframe ``<= n``. This is what makes "turn the patch on 20 tokens into the
|
|
6
|
+
answer" expressible::
|
|
7
|
+
|
|
8
|
+
StrengthSchedule({0: 0.0, 20: 1.0, 40: 2.0})
|
|
9
|
+
|
|
10
|
+
index: 0 ... 19 20 ... 39 40 ...
|
|
11
|
+
strength: 0.0 1.0 2.0
|
|
12
|
+
|
|
13
|
+
Optional linear interpolation smooths the transitions, which matters because an
|
|
14
|
+
abrupt large change in the residual stream mid-generation can itself derail the
|
|
15
|
+
model -- a confound worth being able to rule out.
|
|
16
|
+
|
|
17
|
+
Everything here is pure arithmetic so it can be tested without a model.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Mapping
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StrengthSchedule:
|
|
28
|
+
"""Piecewise strength multiplier over generated-token index.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
keyframes:
|
|
33
|
+
Mapping of token index -> strength multiplier. Indices count *generated*
|
|
34
|
+
tokens, starting at 0 for the first token the model produces; prompt
|
|
35
|
+
tokens are not counted.
|
|
36
|
+
interpolate:
|
|
37
|
+
When True, values between keyframes are linearly interpolated. When
|
|
38
|
+
False (default) the schedule is a step function.
|
|
39
|
+
default:
|
|
40
|
+
Strength used before the first keyframe when index 0 is not specified.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
keyframes: Mapping[int, float]
|
|
44
|
+
interpolate: bool = False
|
|
45
|
+
default: float = 1.0
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if not self.keyframes:
|
|
49
|
+
raise ValueError("a schedule needs at least one keyframe")
|
|
50
|
+
normalized: dict[int, float] = {}
|
|
51
|
+
for key, value in self.keyframes.items():
|
|
52
|
+
try:
|
|
53
|
+
index = int(key)
|
|
54
|
+
except (TypeError, ValueError) as exc:
|
|
55
|
+
raise ValueError(f"schedule keys must be integers, got {key!r}") from exc
|
|
56
|
+
if index < 0:
|
|
57
|
+
raise ValueError(f"schedule token index must be >= 0, got {index}")
|
|
58
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
59
|
+
raise ValueError(f"schedule value at {index} must be numeric, got {value!r}")
|
|
60
|
+
normalized[index] = float(value)
|
|
61
|
+
# frozen dataclass: bypass __setattr__ to install the cleaned mapping
|
|
62
|
+
object.__setattr__(self, "keyframes", dict(sorted(normalized.items())))
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def steps(self) -> list[int]:
|
|
66
|
+
"""Sorted keyframe indices."""
|
|
67
|
+
return list(self.keyframes)
|
|
68
|
+
|
|
69
|
+
def strength_at(self, token_index: int) -> float:
|
|
70
|
+
"""Strength multiplier for the given generated-token index.
|
|
71
|
+
|
|
72
|
+
>>> s = StrengthSchedule({0: 0.0, 20: 1.0, 40: 2.0})
|
|
73
|
+
>>> s.strength_at(0), s.strength_at(19), s.strength_at(20), s.strength_at(100)
|
|
74
|
+
(0.0, 0.0, 1.0, 2.0)
|
|
75
|
+
"""
|
|
76
|
+
if token_index < 0:
|
|
77
|
+
raise ValueError(f"token_index must be >= 0, got {token_index}")
|
|
78
|
+
|
|
79
|
+
steps = self.steps
|
|
80
|
+
# Before the first keyframe: fall back to `default`.
|
|
81
|
+
if token_index < steps[0]:
|
|
82
|
+
return self.default
|
|
83
|
+
|
|
84
|
+
# Find the last keyframe at or before token_index.
|
|
85
|
+
lo = 0
|
|
86
|
+
for i, step in enumerate(steps):
|
|
87
|
+
if step <= token_index:
|
|
88
|
+
lo = i
|
|
89
|
+
else:
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
left = steps[lo]
|
|
93
|
+
left_value = self.keyframes[left]
|
|
94
|
+
if not self.interpolate or lo + 1 >= len(steps):
|
|
95
|
+
return left_value
|
|
96
|
+
|
|
97
|
+
right = steps[lo + 1]
|
|
98
|
+
right_value = self.keyframes[right]
|
|
99
|
+
span = right - left
|
|
100
|
+
if span <= 0: # defensive; sorted unique keys make this unreachable
|
|
101
|
+
return left_value
|
|
102
|
+
alpha = (token_index - left) / span
|
|
103
|
+
return left_value + alpha * (right_value - left_value)
|
|
104
|
+
|
|
105
|
+
def is_constant(self) -> bool:
|
|
106
|
+
"""True if the schedule never changes strength."""
|
|
107
|
+
values = set(self.keyframes.values())
|
|
108
|
+
if len(values) > 1:
|
|
109
|
+
return False
|
|
110
|
+
return self.steps[0] == 0 or values == {self.default}
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> dict[str, Any]:
|
|
113
|
+
return {
|
|
114
|
+
"keyframes": {str(k): v for k, v in self.keyframes.items()},
|
|
115
|
+
"interpolate": self.interpolate,
|
|
116
|
+
"default": self.default,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "StrengthSchedule":
|
|
121
|
+
"""Build from a serialized schedule.
|
|
122
|
+
|
|
123
|
+
Accepts both the full form ``{"keyframes": {...}, ...}`` and the bare
|
|
124
|
+
``{"0": 0.0, "20": 1.0}`` shorthand used inside patch files.
|
|
125
|
+
"""
|
|
126
|
+
if "keyframes" in data:
|
|
127
|
+
raw = data["keyframes"]
|
|
128
|
+
return cls(
|
|
129
|
+
keyframes={int(k): float(v) for k, v in raw.items()},
|
|
130
|
+
interpolate=bool(data.get("interpolate", False)),
|
|
131
|
+
default=float(data.get("default", 1.0)),
|
|
132
|
+
)
|
|
133
|
+
return cls(keyframes={int(k): float(v) for k, v in data.items()})
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def constant(cls, strength: float = 1.0) -> "StrengthSchedule":
|
|
137
|
+
"""A schedule that holds one strength for the whole generation."""
|
|
138
|
+
return cls(keyframes={0: float(strength)}, default=float(strength))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Local web UI. Requires ``pip install 'brainpatch[ui]'``."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["launch"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "launch":
|
|
8
|
+
from brainpatch.ui.app import launch
|
|
9
|
+
|
|
10
|
+
return launch
|
|
11
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
brainpatch/ui/app.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Local Gradio UI.
|
|
2
|
+
|
|
3
|
+
Runs entirely on the user's machine: their backend, their model, their installed
|
|
4
|
+
patches. No hosted service, no Modal, no telemetry.
|
|
5
|
+
|
|
6
|
+
The UI deliberately shows each patch's ``evidence_level`` next to its slider. If
|
|
7
|
+
a patch has no validated behavioural effect, the interface says so rather than
|
|
8
|
+
implying that moving the slider does something known.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
BASELINE_LABEL = "BASELINE (patch off)"
|
|
16
|
+
PATCHED_LABEL = "PATCHED"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def launch(
|
|
20
|
+
*,
|
|
21
|
+
model: str | None = None,
|
|
22
|
+
backend: str = "auto",
|
|
23
|
+
device: str = "auto",
|
|
24
|
+
host: str = "127.0.0.1",
|
|
25
|
+
port: int = 7860,
|
|
26
|
+
share: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Build and serve the UI."""
|
|
29
|
+
import gradio as gr
|
|
30
|
+
|
|
31
|
+
from brainpatch.patch.registry import default_registry
|
|
32
|
+
from brainpatch.runtime.auto import available_backends
|
|
33
|
+
from brainpatch.runtime.base import GenerationConfig
|
|
34
|
+
from brainpatch.runtime.model import BrainPatchedModel
|
|
35
|
+
|
|
36
|
+
registry = default_registry()
|
|
37
|
+
state: dict[str, Any] = {"model": None, "model_id": None}
|
|
38
|
+
|
|
39
|
+
def installed_names() -> list[str]:
|
|
40
|
+
return [p.name for p in registry.list_patches()]
|
|
41
|
+
|
|
42
|
+
def ensure_model(model_id: str, backend_name: str) -> BrainPatchedModel:
|
|
43
|
+
if state["model"] is None or state["model_id"] != (model_id, backend_name):
|
|
44
|
+
if state["model"] is not None:
|
|
45
|
+
state["model"].unload()
|
|
46
|
+
state["model"] = BrainPatchedModel.from_pretrained(
|
|
47
|
+
model_id, backend=backend_name, device=device
|
|
48
|
+
)
|
|
49
|
+
state["model_id"] = (model_id, backend_name)
|
|
50
|
+
return state["model"]
|
|
51
|
+
|
|
52
|
+
def run_compare(
|
|
53
|
+
model_id: str,
|
|
54
|
+
backend_name: str,
|
|
55
|
+
patch_name: str,
|
|
56
|
+
prompt: str,
|
|
57
|
+
strength: float,
|
|
58
|
+
max_tokens: int,
|
|
59
|
+
temperature: float,
|
|
60
|
+
use_schedule: bool,
|
|
61
|
+
turn_on_at: int,
|
|
62
|
+
) -> tuple[str, str, str]:
|
|
63
|
+
if not model_id:
|
|
64
|
+
return "", "", "Enter a model id first."
|
|
65
|
+
if not prompt.strip():
|
|
66
|
+
return "", "", "Enter a prompt."
|
|
67
|
+
try:
|
|
68
|
+
patched = ensure_model(model_id, backend_name)
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
return "", "", f"Could not load model: {exc}"
|
|
71
|
+
|
|
72
|
+
for name in list(patched.list_patches()):
|
|
73
|
+
patched.remove_patch(name)
|
|
74
|
+
|
|
75
|
+
info_bits = []
|
|
76
|
+
if patch_name and patch_name != "(none)":
|
|
77
|
+
try:
|
|
78
|
+
handle = patched.install(patch_name, strength=strength)
|
|
79
|
+
if use_schedule:
|
|
80
|
+
handle.schedule = {0: 0.0, int(turn_on_at): 1.0}
|
|
81
|
+
info_bits.append(
|
|
82
|
+
f"patch **{handle.name}** · strength {handle.strength} · "
|
|
83
|
+
f"evidence `{handle.evidence_level}`"
|
|
84
|
+
)
|
|
85
|
+
if handle.evidence_level in {"none", "correlational"}:
|
|
86
|
+
info_bits.append(
|
|
87
|
+
"⚠️ This patch has **no validated behavioural effect**. "
|
|
88
|
+
"Any difference below is an activation perturbation, not a "
|
|
89
|
+
"demonstrated capability."
|
|
90
|
+
)
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
return "", "", f"Could not install patch: {exc}"
|
|
93
|
+
|
|
94
|
+
cfg = GenerationConfig(max_new_tokens=int(max_tokens), temperature=float(temperature))
|
|
95
|
+
try:
|
|
96
|
+
result = patched.compare(prompt, cfg)
|
|
97
|
+
except Exception as exc: # noqa: BLE001
|
|
98
|
+
return "", "", f"Generation failed: {exc}"
|
|
99
|
+
|
|
100
|
+
if result["baseline"] == result["patched"]:
|
|
101
|
+
info_bits.append("Outputs are identical at this strength on this prompt.")
|
|
102
|
+
return result["baseline"], result["patched"], "\n\n".join(info_bits)
|
|
103
|
+
|
|
104
|
+
def patch_details(name: str) -> str:
|
|
105
|
+
if not name or name == "(none)":
|
|
106
|
+
return "Select a patch."
|
|
107
|
+
try:
|
|
108
|
+
loaded = registry.get(name).load()
|
|
109
|
+
except Exception as exc: # noqa: BLE001
|
|
110
|
+
return f"Could not read patch: {exc}"
|
|
111
|
+
manifest = loaded.manifest
|
|
112
|
+
lines = [
|
|
113
|
+
f"# {manifest.name}",
|
|
114
|
+
"",
|
|
115
|
+
manifest.description or "_no description_",
|
|
116
|
+
"",
|
|
117
|
+
f"- base model: `{manifest.base_model.model_id}`",
|
|
118
|
+
f"- revision: `{manifest.base_model.revision or 'unpinned'}`",
|
|
119
|
+
f"- layers: {manifest.layers}",
|
|
120
|
+
f"- size: {loaded.archive_bytes / 1024:.1f} KB",
|
|
121
|
+
f"- evidence level: **{manifest.evidence_level}**",
|
|
122
|
+
"",
|
|
123
|
+
"## Backend compatibility",
|
|
124
|
+
]
|
|
125
|
+
if manifest.compatibility:
|
|
126
|
+
for backend_name, entry in sorted(manifest.compatibility.items()):
|
|
127
|
+
lines.append(f"- `{backend_name}`: **{entry.get('status', 'unsupported')}**")
|
|
128
|
+
else:
|
|
129
|
+
lines.append("- none recorded")
|
|
130
|
+
if manifest.evaluation:
|
|
131
|
+
lines += ["", "## Recorded evaluation", "```json", _pretty(manifest.evaluation), "```"]
|
|
132
|
+
else:
|
|
133
|
+
lines += ["", "_No evaluation recorded: this patch has no measured effect._"]
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
def backend_status() -> str:
|
|
137
|
+
rows = ["| backend | status | detail |", "|---|---|---|"]
|
|
138
|
+
for status in available_backends():
|
|
139
|
+
mark = "available" if status.available else "unavailable"
|
|
140
|
+
rows.append(f"| `{status.name}` | {mark} | {status.detail} |")
|
|
141
|
+
return "\n".join(rows)
|
|
142
|
+
|
|
143
|
+
with gr.Blocks(title="BrainPatch") as blocks:
|
|
144
|
+
gr.Markdown(
|
|
145
|
+
"# BrainPatch\n"
|
|
146
|
+
"Tiny activation patches for frozen language models. "
|
|
147
|
+
"Everything here runs locally on your machine.\n\n"
|
|
148
|
+
"> Feature directions are not concepts. A patch changes activations; "
|
|
149
|
+
"whether that produces a *specific* behaviour is an empirical question "
|
|
150
|
+
"answered by each patch's evidence level."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
with gr.Tab("Compare"):
|
|
154
|
+
with gr.Row():
|
|
155
|
+
with gr.Column(scale=2):
|
|
156
|
+
model_box = gr.Textbox(label="Model", value=model or "", placeholder="Qwen/Qwen2.5-1.5B-Instruct")
|
|
157
|
+
prompt_box = gr.Textbox(label="Prompt", lines=5, value="Evaluate my idea.")
|
|
158
|
+
with gr.Row():
|
|
159
|
+
max_tokens = gr.Slider(16, 512, value=128, step=16, label="max new tokens")
|
|
160
|
+
temperature = gr.Slider(0.0, 1.5, value=0.0, step=0.1, label="temperature")
|
|
161
|
+
with gr.Column(scale=1):
|
|
162
|
+
backend_box = gr.Dropdown(
|
|
163
|
+
["auto", "transformers", "llamacpp", "vllm", "mlx"],
|
|
164
|
+
value=backend,
|
|
165
|
+
label="backend",
|
|
166
|
+
)
|
|
167
|
+
patch_box = gr.Dropdown(
|
|
168
|
+
["(none)"] + installed_names(), value="(none)", label="patch"
|
|
169
|
+
)
|
|
170
|
+
strength_box = gr.Slider(-8, 8, value=1.0, step=0.1, label="strength")
|
|
171
|
+
schedule_box = gr.Checkbox(label="dynamic schedule", value=False)
|
|
172
|
+
turn_on_box = gr.Slider(0, 128, value=24, step=4, label="turn on at token")
|
|
173
|
+
go = gr.Button("Generate", variant="primary")
|
|
174
|
+
with gr.Row():
|
|
175
|
+
baseline_out = gr.Textbox(label=BASELINE_LABEL, lines=14)
|
|
176
|
+
patched_out = gr.Textbox(label=PATCHED_LABEL, lines=14)
|
|
177
|
+
info_out = gr.Markdown()
|
|
178
|
+
go.click(
|
|
179
|
+
run_compare,
|
|
180
|
+
[model_box, backend_box, patch_box, prompt_box, strength_box,
|
|
181
|
+
max_tokens, temperature, schedule_box, turn_on_box],
|
|
182
|
+
[baseline_out, patched_out, info_out],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
with gr.Tab("Patch Inspector"):
|
|
186
|
+
inspect_box = gr.Dropdown(
|
|
187
|
+
["(none)"] + installed_names(), value="(none)", label="installed patch"
|
|
188
|
+
)
|
|
189
|
+
details = gr.Markdown()
|
|
190
|
+
inspect_box.change(patch_details, [inspect_box], [details])
|
|
191
|
+
|
|
192
|
+
with gr.Tab("Backends"):
|
|
193
|
+
gr.Markdown(backend_status())
|
|
194
|
+
|
|
195
|
+
blocks.launch(server_name=host, server_port=port, share=share)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _pretty(data: Any) -> str:
|
|
199
|
+
import json
|
|
200
|
+
|
|
201
|
+
return json.dumps(data, indent=2, sort_keys=True)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Verify that a `.brainpatch` artifact encodes the intervention it claims to.
|
|
2
|
+
|
|
3
|
+
Checksums prove a file was not modified. They do not prove the sign is right, the
|
|
4
|
+
layer is right, or that the runtime applies the intervention where the manifest
|
|
5
|
+
says it does. This package covers the second half.
|
|
6
|
+
|
|
7
|
+
Four levels, cheapest first:
|
|
8
|
+
|
|
9
|
+
``structural``
|
|
10
|
+
The archive is well-formed, its checksums verify, the manifest is
|
|
11
|
+
self-consistent, tensor shapes match, and the declared base model is the one
|
|
12
|
+
you are loading. Needs no model and no GPU.
|
|
13
|
+
|
|
14
|
+
``numerical``
|
|
15
|
+
The applied delta (``coefficient x vector``) matches a trusted reference in
|
|
16
|
+
**signed** direction and magnitude. Requires a reference artifact. The
|
|
17
|
+
reference must be signed: a cosine against an unsigned reference cannot see a
|
|
18
|
+
sign error, which is how this project once shipped one.
|
|
19
|
+
|
|
20
|
+
``execution``
|
|
21
|
+
The runtime actually applies the intervention at the declared layer and on
|
|
22
|
+
the declared passes. Read from ``resolve_edits``, not from the manifest --
|
|
23
|
+
a manifest declaring ``site: prompt`` and a runtime steering every generated
|
|
24
|
+
token is exactly the disagreement this level exists to find. Requires a
|
|
25
|
+
loaded backend.
|
|
26
|
+
|
|
27
|
+
``behavioural``
|
|
28
|
+
The artifact reproduces a recorded behavioural result within a tolerance you
|
|
29
|
+
set in advance. Optional, and the most expensive.
|
|
30
|
+
|
|
31
|
+
Empirically (``experiments/artifact_fidelity_v1``), across thirteen controlled
|
|
32
|
+
corruptions of a real artifact: schema, checksum, shape and model-compatibility
|
|
33
|
+
checks caught **none** of eleven real defects, while signed direction, delta norm
|
|
34
|
+
and execution tracing together caught **all** of them. Layer and site defects were
|
|
35
|
+
invisible to every file-level check -- only execution tracing found them.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from brainpatch.verify.checks import (
|
|
39
|
+
COSINE_TOLERANCE,
|
|
40
|
+
DETECTION_LAYERS,
|
|
41
|
+
NORM_TOLERANCE,
|
|
42
|
+
Reference,
|
|
43
|
+
reference_from,
|
|
44
|
+
run_execution_trace,
|
|
45
|
+
run_runtime_smoke,
|
|
46
|
+
run_static_layers,
|
|
47
|
+
)
|
|
48
|
+
from brainpatch.verify.workflow import (
|
|
49
|
+
VerificationResult,
|
|
50
|
+
verify_artifact,
|
|
51
|
+
verify_behavioural_regression,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"COSINE_TOLERANCE",
|
|
56
|
+
"DETECTION_LAYERS",
|
|
57
|
+
"NORM_TOLERANCE",
|
|
58
|
+
"Reference",
|
|
59
|
+
"VerificationResult",
|
|
60
|
+
"reference_from",
|
|
61
|
+
"run_execution_trace",
|
|
62
|
+
"run_runtime_smoke",
|
|
63
|
+
"run_static_layers",
|
|
64
|
+
"verify_artifact",
|
|
65
|
+
"verify_behavioural_regression",
|
|
66
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Behavioural fidelity: does the deployed artifact reproduce the validated effect?
|
|
2
|
+
|
|
3
|
+
The only detection layer that is not blind by construction, and the only one that
|
|
4
|
+
would have caught the sign defect this project shipped.
|
|
5
|
+
|
|
6
|
+
**The product path is the only path.** Every measurement here runs
|
|
7
|
+
``load_patch`` -> ``validate_patch`` -> ``install_patch`` and then generates
|
|
8
|
+
through the runtime's *own installed hooks*, so ``resolve_edits``, the site
|
|
9
|
+
gating and the vector cache are all exercised. Reconstructing the research
|
|
10
|
+
direction and testing that is not artifact validation -- it is precisely what let
|
|
11
|
+
the sign defect through.
|
|
12
|
+
|
|
13
|
+
Generation is batched. That is an I/O choice, not a path change: the hooks are
|
|
14
|
+
the backend's, installed by ``install_patch``, and the pass counter the site
|
|
15
|
+
gating depends on advances identically whether one prompt or twenty-four are in
|
|
16
|
+
flight. :func:`unbatched_reference` re-measures through the fully public
|
|
17
|
+
``backend.generate`` path so the two can be compared rather than assumed equal.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
#: Fixed for every condition. Batch composition changes left-padding and can flip
|
|
25
|
+
#: argmax on near-ties, so it must never vary between reference and variant.
|
|
26
|
+
BATCH_SIZE = 24
|
|
27
|
+
GENERATION_KWARGS = {"max_new_tokens": 96, "do_sample": False}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_through_installed_patch(
|
|
31
|
+
backend: Any, prompts: list[str], *, batch_size: int = BATCH_SIZE
|
|
32
|
+
) -> list[str]:
|
|
33
|
+
"""Batched generation driven by the backend's installed hooks.
|
|
34
|
+
|
|
35
|
+
Hooks are installed once. The pass counter is reset per batch, exactly as
|
|
36
|
+
``backend.generate`` resets it per call -- pass 0 is the prompt, pass n>0 is
|
|
37
|
+
generated token n-1, which is what the ``site`` gating reads.
|
|
38
|
+
"""
|
|
39
|
+
import torch
|
|
40
|
+
|
|
41
|
+
tokenizer = backend.tokenizer
|
|
42
|
+
pad_id = tokenizer.pad_token_id or tokenizer.eos_token_id
|
|
43
|
+
original_side = tokenizer.padding_side
|
|
44
|
+
tokenizer.padding_side = "left"
|
|
45
|
+
|
|
46
|
+
outputs: list[str] = []
|
|
47
|
+
backend._apply_to_prompt = True
|
|
48
|
+
backend._trace = []
|
|
49
|
+
backend._install_hooks()
|
|
50
|
+
try:
|
|
51
|
+
for start in range(0, len(prompts), batch_size):
|
|
52
|
+
chunk = prompts[start : start + batch_size]
|
|
53
|
+
rendered = [
|
|
54
|
+
tokenizer.apply_chat_template(
|
|
55
|
+
[{"role": "user", "content": p}], tokenize=False, add_generation_prompt=True
|
|
56
|
+
)
|
|
57
|
+
for p in chunk
|
|
58
|
+
]
|
|
59
|
+
encoded = tokenizer(
|
|
60
|
+
rendered, return_tensors="pt", padding=True, add_special_tokens=False
|
|
61
|
+
).to(backend.device)
|
|
62
|
+
# Same reset the public generate() performs between calls.
|
|
63
|
+
backend._pass_counter = 0
|
|
64
|
+
backend._current_pass = 0
|
|
65
|
+
with torch.inference_mode():
|
|
66
|
+
generated = backend.model.generate(
|
|
67
|
+
**encoded, pad_token_id=pad_id, **GENERATION_KWARGS
|
|
68
|
+
)
|
|
69
|
+
for row in range(len(chunk)):
|
|
70
|
+
new_tokens = generated[row, encoded["input_ids"].shape[1] :]
|
|
71
|
+
outputs.append(tokenizer.decode(new_tokens, skip_special_tokens=True))
|
|
72
|
+
finally:
|
|
73
|
+
backend._remove_hooks()
|
|
74
|
+
tokenizer.padding_side = original_side
|
|
75
|
+
return outputs
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def unbatched_reference(backend: Any, prompts: list[str]) -> list[str]:
|
|
79
|
+
"""Fully public product path, one prompt at a time.
|
|
80
|
+
|
|
81
|
+
Slow (~3.3 s/item against ~0.15 s/item batched), so it is run once on the
|
|
82
|
+
reference artifact to establish that the batched path agrees, rather than on
|
|
83
|
+
every variant.
|
|
84
|
+
"""
|
|
85
|
+
from brainpatch.runtime.base import GenerationConfig
|
|
86
|
+
|
|
87
|
+
config = GenerationConfig(
|
|
88
|
+
max_new_tokens=GENERATION_KWARGS["max_new_tokens"], temperature=0.0
|
|
89
|
+
)
|
|
90
|
+
return [backend.generate(prompt, config) for prompt in prompts]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def measure(examples: Any, texts: list[str]) -> dict[str, Any]:
|
|
94
|
+
"""Behavioural summary using the frozen evaluator. Nothing is tuned."""
|
|
95
|
+
from brainpatch.research.generation_eval import summarise
|
|
96
|
+
|
|
97
|
+
polarities = [str(e.metadata.get("polarity", "false_claim")) for e in examples]
|
|
98
|
+
return summarise(polarities, texts)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def commitment_effect(behaviour: dict[str, Any], baseline: dict[str, Any]) -> dict[str, float]:
|
|
102
|
+
"""The construct-correct reading of this artifact.
|
|
103
|
+
|
|
104
|
+
Reported as **commitment / anti-evasion**, never as anti-sycophancy: the
|
|
105
|
+
construct-validity analysis found only ~22% of the correction gain came from
|
|
106
|
+
agreement moving to challenge, and that sycophantic agreement *rose*. The
|
|
107
|
+
evasion rate is the primary quantity because it is what the artifact was
|
|
108
|
+
shown to move.
|
|
109
|
+
"""
|
|
110
|
+
return {
|
|
111
|
+
"correction_rate": behaviour["correction_rate_false_claims"],
|
|
112
|
+
"correction_gain": (
|
|
113
|
+
behaviour["correction_rate_false_claims"]
|
|
114
|
+
- baseline["correction_rate_false_claims"]
|
|
115
|
+
),
|
|
116
|
+
"evasion_rate": behaviour["other_rate_false_claims"],
|
|
117
|
+
"evasion_reduction": (
|
|
118
|
+
baseline["other_rate_false_claims"] - behaviour["other_rate_false_claims"]
|
|
119
|
+
),
|
|
120
|
+
"sycophantic_rate": behaviour["sycophantic_agreement_rate_false_claims"],
|
|
121
|
+
"sis": behaviour["selective_independence_score"],
|
|
122
|
+
"degenerate_rate": behaviour["degenerate_rate"],
|
|
123
|
+
"mean_response_chars": behaviour["mean_response_chars"],
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def behavioural_delta(variant: dict[str, float], reference: dict[str, float]) -> float:
|
|
128
|
+
"""Primary fidelity distance: absolute difference in correction gain.
|
|
129
|
+
|
|
130
|
+
A single pre-registered scalar, chosen before results. Exact string identity
|
|
131
|
+
is deliberately not used -- batching and F16 storage both flip argmax on
|
|
132
|
+
near-ties, and a Q4 quantization agrees with its own reference on only ~57%
|
|
133
|
+
of greedy outputs, so string agreement measures decoding noise more than it
|
|
134
|
+
measures fidelity.
|
|
135
|
+
"""
|
|
136
|
+
return abs(variant["correction_gain"] - reference["correction_gain"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def transition_counts(
|
|
140
|
+
examples: Any, before: list[str], after: list[str], polarity: str = "false_claim"
|
|
141
|
+
) -> dict[str, int]:
|
|
142
|
+
"""Per-item label transitions, so two artifacts reaching the same rate by
|
|
143
|
+
different routes are not mistaken for each other."""
|
|
144
|
+
from collections import Counter
|
|
145
|
+
|
|
146
|
+
from brainpatch.research.generation_eval import per_item_labels
|
|
147
|
+
|
|
148
|
+
polarities = [str(e.metadata.get("polarity", "false_claim")) for e in examples]
|
|
149
|
+
rows_b = per_item_labels(polarities, before)
|
|
150
|
+
rows_a = per_item_labels(polarities, after)
|
|
151
|
+
pairs = Counter(
|
|
152
|
+
(b["label"], a["label"])
|
|
153
|
+
for b, a in zip(rows_b, rows_a)
|
|
154
|
+
if b["polarity"] == polarity
|
|
155
|
+
)
|
|
156
|
+
return {f"{a}->{b}": n for (a, b), n in sorted(pairs.items())}
|