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,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())}