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/cli.py ADDED
@@ -0,0 +1,825 @@
1
+ """The ``brainpatch`` command-line interface.
2
+
3
+ Product-first. The commands a *user* needs -- install, list, inspect, run, chat,
4
+ compare, serve, doctor -- work with only the core package plus whichever
5
+ inference backend they chose. Nothing here requires Modal, a hosted service, or
6
+ network access once a patch and model are local.
7
+
8
+ Backends are imported lazily, so ``brainpatch list`` and ``brainpatch inspect``
9
+ run instantly on a machine with no ML stack at all. ``brainpatch doctor`` is
10
+ built to work in exactly that situation, since its whole job is reporting what
11
+ is and is not installed.
12
+
13
+ Research commands live under ``brainpatch research`` and are documented as
14
+ requiring the ``research`` extra.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any, Optional
23
+
24
+ import typer
25
+ from rich.console import Console
26
+ from rich.panel import Panel
27
+ from rich.table import Table
28
+
29
+ from brainpatch import __version__
30
+
31
+ console = Console()
32
+ err_console = Console(stderr=True)
33
+
34
+ app = typer.Typer(
35
+ name="brainpatch",
36
+ help="Portable, reversible activation-space interventions for frozen language models.",
37
+ no_args_is_help=True,
38
+ add_completion=False,
39
+ )
40
+ research_app = typer.Typer(
41
+ help="Patch-authoring tools. Requires: pip install 'brainpatch[research]'",
42
+ no_args_is_help=True,
43
+ )
44
+ app.add_typer(research_app, name="research")
45
+
46
+
47
+ def _fail(message: str, code: int = 1) -> None:
48
+ err_console.print(f"[red]error:[/red] {message}")
49
+ raise typer.Exit(code=code)
50
+
51
+
52
+ @app.callback(invoke_without_command=True)
53
+ def _root(
54
+ ctx: typer.Context,
55
+ version: bool = typer.Option(False, "--version", help="Print the version and exit."),
56
+ ) -> None:
57
+ if version:
58
+ console.print(f"brainpatch {__version__}")
59
+ raise typer.Exit()
60
+ if ctx.invoked_subcommand is None:
61
+ console.print(ctx.get_help())
62
+ raise typer.Exit()
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # patch management
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ @app.command()
71
+ def install(
72
+ ref: str = typer.Argument(..., help="Path to a .brainpatch file, or a 'owner/repo' HF reference."),
73
+ force: bool = typer.Option(False, "--force", help="Replace an already-installed patch."),
74
+ offline: bool = typer.Option(False, "--offline", help="Refuse any network access."),
75
+ ) -> None:
76
+ """Install a patch into the local registry (~/.brainpatch).
77
+
78
+ Downloads only the patch artifact -- never the base model.
79
+ """
80
+ from brainpatch.patch.registry import RegistryError, default_registry
81
+
82
+ registry = default_registry()
83
+ try:
84
+ installed = registry.install(ref, overwrite=force, offline=offline)
85
+ except RegistryError as exc:
86
+ _fail(str(exc))
87
+ except Exception as exc: # noqa: BLE001
88
+ _fail(f"could not install {ref!r}: {exc}")
89
+
90
+ loaded = installed.load()
91
+ manifest = loaded.manifest
92
+ console.print(f"[green]Installed:[/green] [bold]{installed.name}[/bold]")
93
+ console.print(f" model: {manifest.base_model.model_id}")
94
+ console.print(f" size: {installed.size_bytes / 1024:.1f} KB")
95
+ console.print(f" evidence: {_evidence_markup(manifest.evidence_level)}")
96
+ if manifest.evidence_level in {"none", "correlational"}:
97
+ console.print(
98
+ " [yellow]This patch has no validated behavioural effect. "
99
+ "Its name is a label, not a claim.[/yellow]"
100
+ )
101
+
102
+
103
+ @app.command()
104
+ def uninstall(name: str = typer.Argument(..., help="Installed patch name.")) -> None:
105
+ """Remove a patch from the local registry."""
106
+ from brainpatch.patch.registry import RegistryError, default_registry
107
+
108
+ try:
109
+ default_registry().uninstall(name)
110
+ except RegistryError as exc:
111
+ _fail(str(exc))
112
+ console.print(f"[green]Uninstalled[/green] {name}")
113
+
114
+
115
+ @app.command("list")
116
+ def list_command(
117
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
118
+ ) -> None:
119
+ """List installed patches."""
120
+ from brainpatch.patch.registry import default_registry
121
+
122
+ installed = default_registry().list_patches()
123
+ if json_output:
124
+ rows = []
125
+ for item in installed:
126
+ loaded = item.load()
127
+ rows.append({**loaded.describe(), "installed_bytes": item.size_bytes})
128
+ console.print_json(json.dumps(rows))
129
+ return
130
+
131
+ if not installed:
132
+ console.print("No patches installed.")
133
+ console.print("\n brainpatch install <file.brainpatch>")
134
+ console.print(" brainpatch install owner/repo")
135
+ return
136
+
137
+ table = Table(title="Installed BrainPatches")
138
+ table.add_column("name")
139
+ table.add_column("base model")
140
+ table.add_column("layers", justify="right")
141
+ table.add_column("size", justify="right")
142
+ table.add_column("evidence")
143
+ for item in installed:
144
+ manifest = item.load().manifest
145
+ table.add_row(
146
+ item.name,
147
+ manifest.base_model.model_id,
148
+ ",".join(str(layer) for layer in manifest.layers),
149
+ f"{item.size_bytes / 1024:.1f} KB",
150
+ _evidence_markup(manifest.evidence_level),
151
+ )
152
+ console.print(table)
153
+
154
+
155
+ @app.command()
156
+ def inspect(
157
+ patch: str = typer.Argument(..., help="Installed name or path to a .brainpatch file."),
158
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable output."),
159
+ ) -> None:
160
+ """Show everything a patch declares, including what it does not claim."""
161
+ loaded = _load_patch_arg(patch)
162
+ manifest = loaded.manifest
163
+
164
+ if json_output:
165
+ console.print_json(json.dumps(manifest.to_dict()))
166
+ return
167
+
168
+ console.print(
169
+ Panel(
170
+ manifest.description or "[dim]no description[/dim]",
171
+ title=f"[bold]{manifest.name}[/bold] (format {manifest.format_version})",
172
+ )
173
+ )
174
+
175
+ table = Table(show_header=False, box=None)
176
+ spec = manifest.base_model
177
+ table.add_row("base model", spec.model_id)
178
+ table.add_row("revision", spec.revision or "[dim]unpinned[/dim]")
179
+ table.add_row("architecture", spec.architecture or "[dim]unknown[/dim]")
180
+ table.add_row("geometry", f"hidden {spec.hidden_size}, {spec.num_layers} layers")
181
+ table.add_row("evidence", _evidence_markup(manifest.evidence_level))
182
+ table.add_row("strength", f"default {manifest.default_strength}, max ±{manifest.max_abs_strength}")
183
+ table.add_row("license", manifest.license)
184
+ table.add_row("size", f"{loaded.archive_bytes / 1024:.1f} KB")
185
+ console.print(table)
186
+
187
+ console.print("\n[bold]interventions[/bold]")
188
+ for item in manifest.interventions:
189
+ tensor = loaded.vectors.get(item.vector)
190
+ dims = tensor.shape[0] if tensor else "?"
191
+ console.print(
192
+ f" L{item.layer:<3} {item.hook:<14} {item.vector:<12} "
193
+ f"coefficient={item.coefficient:+.4f} ({dims}-d {tensor.dtype if tensor else ''})"
194
+ )
195
+
196
+ if manifest.compatibility:
197
+ console.print("\n[bold]backend compatibility[/bold]")
198
+ for backend, entry in sorted(manifest.compatibility.items()):
199
+ status = str(entry.get("status", "unsupported"))
200
+ colour = {"verified": "green", "experimental": "yellow"}.get(status, "red")
201
+ extra = {k: v for k, v in entry.items() if k != "status"}
202
+ suffix = f" {extra}" if extra else ""
203
+ console.print(f" {backend:<14} [{colour}]{status}[/{colour}]{suffix}")
204
+ else:
205
+ console.print("\n[yellow]No backend compatibility recorded.[/yellow]")
206
+
207
+ if manifest.evaluation:
208
+ console.print("\n[bold]recorded evaluation[/bold]")
209
+ console.print_json(json.dumps(manifest.evaluation))
210
+ else:
211
+ console.print("\n[yellow]No evaluation recorded: this patch has no measured effect.[/yellow]")
212
+
213
+ if manifest.provenance:
214
+ console.print("\n[bold]provenance[/bold] [dim](research metadata; unused at runtime)[/dim]")
215
+ console.print_json(json.dumps(manifest.provenance))
216
+
217
+
218
+ @app.command()
219
+ def validate(
220
+ patch: str = typer.Argument(..., help="Installed name or path to a .brainpatch file."),
221
+ model: Optional[str] = typer.Option(None, "--model", help="Check against a loaded model too."),
222
+ backend: str = typer.Option("auto", "--backend"),
223
+ mode: str = typer.Option("strict", "--mode", help="strict | architecture | unsafe"),
224
+ ) -> None:
225
+ """Validate a patch archive or a directory of patches."""
226
+ target = Path(patch).expanduser()
227
+ if target.is_dir():
228
+ # A directory may hold runtime artifacts, research patches, or both.
229
+ from brainpatch.patch.loader import PatchLoadError
230
+ from brainpatch.patch.loader import load_patch as load_runtime
231
+ from brainpatch.schemas.patch import PatchValidationError
232
+ from brainpatch.schemas.patch_io import load_patch as load_research
233
+
234
+ files = sorted(list(target.glob("*.brainpatch")) + list(target.glob("*.json")))
235
+ if not files:
236
+ console.print(f"[yellow]no patches found in {target}[/yellow]")
237
+ raise typer.Exit()
238
+ failed = 0
239
+ for path in files:
240
+ try:
241
+ if path.suffix == ".brainpatch":
242
+ console.print(f"[green]ok[/green] {path.name}: {load_runtime(path).manifest.summary()}")
243
+ else:
244
+ console.print(f"[green]ok[/green] {path.name}: {load_research(path).summary()}")
245
+ except (PatchLoadError, PatchValidationError, OSError) as exc:
246
+ failed += 1
247
+ console.print(f"[red]invalid[/red] {path.name}: {exc}")
248
+ if failed:
249
+ raise typer.Exit(code=1)
250
+ return
251
+
252
+ loaded = _load_patch_arg(patch)
253
+ console.print(f"[green]ok[/green] archive, checksums and manifest are valid")
254
+ console.print(f" {loaded.manifest.summary()}")
255
+
256
+ if model is None:
257
+ return
258
+
259
+ from brainpatch.runtime.model import BrainPatchedModel
260
+
261
+ try:
262
+ patched = BrainPatchedModel.from_pretrained(model, backend=backend)
263
+ except Exception as exc: # noqa: BLE001
264
+ _fail(f"could not load {model!r}: {exc}")
265
+ report = patched.backend.validate_patch(loaded, mode=mode) # type: ignore[arg-type]
266
+ for warning in report.warnings:
267
+ console.print(f"[yellow]warning[/yellow] {warning}")
268
+ if report.ok:
269
+ console.print(f"[green]ok[/green] compatible with {model} in '{mode}' mode")
270
+ else:
271
+ for error in report.errors:
272
+ console.print(f"[red]incompatible[/red] {error}")
273
+ raise typer.Exit(code=1)
274
+
275
+
276
+ @app.command()
277
+ def verify(
278
+ patch: str = typer.Argument(..., help="Installed name or path to a .brainpatch file."),
279
+ reference: Optional[str] = typer.Option(
280
+ None, "--reference", help="Trusted artifact to compare direction and magnitude against."
281
+ ),
282
+ model: Optional[str] = typer.Option(
283
+ None, "--model", help="Load this model to check where the runtime actually intervenes."
284
+ ),
285
+ backend: str = typer.Option("auto", "--backend"),
286
+ json_output: bool = typer.Option(False, "--json"),
287
+ ) -> None:
288
+ """Check that a patch encodes the intervention it claims to.
289
+
290
+ `validate` answers "is this file well-formed and loadable". `verify` answers
291
+ "does it do what it says". Those are different questions: in a controlled
292
+ corruption study every one of eleven real defects passed schema, checksum,
293
+ shape and model-compatibility checks, and layer/site defects were invisible to
294
+ every file-level check -- only tracing the runtime found them.
295
+
296
+ Checks run in four levels, and each one that cannot run is reported as
297
+ `skipped` rather than omitted:
298
+
299
+ structural always (checksums, schema, shape, declared model)
300
+ numerical needs --reference (signed direction, delta norm, coefficient)
301
+ execution needs --model (layer, site and schedule as the runtime applies them)
302
+ behavioural see docs/verification.md
303
+ """
304
+ import json as _json
305
+
306
+ from brainpatch.verify import verify_artifact
307
+
308
+ target = Path(patch).expanduser()
309
+ if not target.is_file():
310
+ installed = _load_patch_arg(patch)
311
+ target = Path(installed.path) if getattr(installed, "path", None) else target
312
+ if not target.is_file():
313
+ _fail(f"no artifact found for {patch!r}")
314
+
315
+ loaded_backend = None
316
+ if model is not None:
317
+ from brainpatch.runtime.model import BrainPatchedModel
318
+
319
+ try:
320
+ loaded_backend = BrainPatchedModel.from_pretrained(model, backend=backend).backend
321
+ except Exception as exc: # noqa: BLE001
322
+ _fail(f"could not load {model!r}: {exc}")
323
+
324
+ result = verify_artifact(
325
+ target,
326
+ reference=Path(reference).expanduser() if reference else None,
327
+ backend=loaded_backend,
328
+ expect_model=model,
329
+ )
330
+
331
+ if json_output:
332
+ console.print_json(_json.dumps(result.to_dict()))
333
+ raise typer.Exit(code=0 if result.faithful else 1)
334
+
335
+ colours = {"pass": "green", "fail": "red", "skipped": "yellow"}
336
+ width = max(len(c.name) for c in result.checks)
337
+ current = None
338
+ for check in result.checks:
339
+ if check.level != current:
340
+ current = check.level
341
+ console.print()
342
+ console.print(f"[bold]{current}[/bold]")
343
+ colour = colours[check.status]
344
+ console.print(
345
+ f" {check.name.ljust(width)} [{colour}]{check.status.upper()}[/{colour}] {check.detail}"
346
+ )
347
+
348
+ console.print()
349
+ if result.skipped_levels:
350
+ console.print(
351
+ f"[yellow]unverified:[/yellow] {', '.join(result.skipped_levels)} "
352
+ "-- not checked is not the same as checked and fine"
353
+ )
354
+ verdict_colour = "green" if result.faithful else "red"
355
+ console.print(f"[{verdict_colour}]{result.verdict}[/{verdict_colour}]")
356
+ raise typer.Exit(code=0 if result.faithful else 1)
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # running
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ @app.command()
365
+ def run(
366
+ prompt: str = typer.Argument(..., help="The prompt to complete."),
367
+ model: str = typer.Option(..., "--model", "-m", help="Base model id or GGUF path."),
368
+ patch: list[str] = typer.Option([], "--patch", "-p", help="Patch(es) to apply. Repeatable."),
369
+ backend: str = typer.Option("auto", "--backend", "-b"),
370
+ device: str = typer.Option("auto", "--device"),
371
+ strength: Optional[float] = typer.Option(None, "--strength", "-s"),
372
+ max_new_tokens: int = typer.Option(128, "--max-tokens"),
373
+ temperature: float = typer.Option(0.0, "--temperature"),
374
+ mode: str = typer.Option("strict", "--compatibility", help="strict | architecture | unsafe"),
375
+ ) -> None:
376
+ """Generate a completion with patches applied."""
377
+ model_obj, _ = _load_model_with_patches(
378
+ model, patch, backend, device, strength, mode
379
+ )
380
+ from brainpatch.runtime.base import GenerationConfig
381
+
382
+ cfg = GenerationConfig(max_new_tokens=max_new_tokens, temperature=temperature)
383
+ console.print(model_obj.generate(prompt, cfg))
384
+
385
+
386
+ @app.command()
387
+ def compare(
388
+ model: str = typer.Option(..., "--model", "-m"),
389
+ patch: list[str] = typer.Option(..., "--patch", "-p", help="Patch(es) to apply."),
390
+ prompt: str = typer.Option(..., "--prompt"),
391
+ backend: str = typer.Option("auto", "--backend", "-b"),
392
+ device: str = typer.Option("auto", "--device"),
393
+ strength: Optional[float] = typer.Option(None, "--strength", "-s"),
394
+ max_new_tokens: int = typer.Option(128, "--max-tokens"),
395
+ temperature: float = typer.Option(0.0, "--temperature"),
396
+ mode: str = typer.Option("strict", "--compatibility"),
397
+ ) -> None:
398
+ """Generate the same prompt with the patch off and on, side by side."""
399
+ model_obj, _ = _load_model_with_patches(model, patch, backend, device, strength, mode)
400
+ from brainpatch.runtime.base import GenerationConfig
401
+
402
+ cfg = GenerationConfig(max_new_tokens=max_new_tokens, temperature=temperature)
403
+ result = model_obj.compare(prompt, cfg)
404
+
405
+ console.print(Panel(result["baseline"], title="[bold]BASELINE[/bold]", border_style="dim"))
406
+ console.print(Panel(result["patched"], title="[bold]PATCHED[/bold]", border_style="cyan"))
407
+ if result["baseline"] == result["patched"]:
408
+ console.print(
409
+ "[yellow]Outputs are identical.[/yellow] The patch had no effect at this "
410
+ "strength on this prompt -- try raising --strength, but check the patch's "
411
+ "measured dose-response first."
412
+ )
413
+
414
+
415
+ @app.command()
416
+ def chat(
417
+ model: str = typer.Option(..., "--model", "-m"),
418
+ patch: list[str] = typer.Option([], "--patch", "-p"),
419
+ backend: str = typer.Option("auto", "--backend", "-b"),
420
+ device: str = typer.Option("auto", "--device"),
421
+ strength: Optional[float] = typer.Option(None, "--strength", "-s"),
422
+ max_new_tokens: int = typer.Option(256, "--max-tokens"),
423
+ temperature: float = typer.Option(0.7, "--temperature"),
424
+ mode: str = typer.Option("strict", "--compatibility"),
425
+ ) -> None:
426
+ """Interactive chat. Type /help for in-session commands."""
427
+ model_obj, handles = _load_model_with_patches(model, patch, backend, device, strength, mode)
428
+ from brainpatch.runtime.base import GenerationConfig
429
+
430
+ cfg = GenerationConfig(max_new_tokens=max_new_tokens, temperature=temperature)
431
+ console.print(
432
+ Panel(
433
+ "/patches list patches\n"
434
+ "/strength <name> <value>\n"
435
+ "/on <name> /off <name>\n"
436
+ "/quit",
437
+ title="brainpatch chat",
438
+ )
439
+ )
440
+ while True:
441
+ try:
442
+ line = console.input("[bold cyan]you[/bold cyan] > ").strip()
443
+ except (EOFError, KeyboardInterrupt):
444
+ console.print()
445
+ break
446
+ if not line:
447
+ continue
448
+ if line.startswith("/"):
449
+ if _handle_chat_command(line, model_obj):
450
+ break
451
+ continue
452
+ console.print("[bold green]model[/bold green] >", end=" ")
453
+ console.print(model_obj.generate(line, cfg))
454
+
455
+
456
+ def _handle_chat_command(line: str, model_obj: Any) -> bool:
457
+ """Handle a ``/`` command. Returns True to exit the loop."""
458
+ parts = line.split()
459
+ command = parts[0].lower()
460
+ if command in {"/quit", "/exit", "/q"}:
461
+ return True
462
+ if command == "/help":
463
+ console.print("/patches /strength <name> <v> /on <name> /off <name> /quit")
464
+ elif command == "/patches":
465
+ for name in model_obj.list_patches():
466
+ console.print(f" {model_obj.patch(name)!r}")
467
+ elif command == "/strength" and len(parts) == 3:
468
+ try:
469
+ actual = model_obj.set_patch_strength(parts[1], float(parts[2]))
470
+ console.print(f" {parts[1]} strength -> {actual}")
471
+ except (KeyError, ValueError) as exc:
472
+ console.print(f"[red]{exc}[/red]")
473
+ elif command in {"/on", "/off"} and len(parts) == 2:
474
+ try:
475
+ model_obj.enable_patch(parts[1]) if command == "/on" else model_obj.disable_patch(parts[1])
476
+ console.print(f" {parts[1]} {'enabled' if command == '/on' else 'disabled'}")
477
+ except KeyError as exc:
478
+ console.print(f"[red]{exc}[/red]")
479
+ else:
480
+ console.print("[yellow]unknown command; /help for the list[/yellow]")
481
+ return False
482
+
483
+
484
+ @app.command()
485
+ def serve(
486
+ model: str = typer.Option(..., "--model", "-m"),
487
+ patch: list[str] = typer.Option([], "--patch", "-p"),
488
+ backend: str = typer.Option("auto", "--backend", "-b"),
489
+ device: str = typer.Option("auto", "--device"),
490
+ strength: Optional[float] = typer.Option(None, "--strength", "-s"),
491
+ host: str = typer.Option("127.0.0.1", "--host"),
492
+ port: int = typer.Option(8000, "--port"),
493
+ mode: str = typer.Option("strict", "--compatibility"),
494
+ ) -> None:
495
+ """Serve an OpenAI-compatible HTTP API with patches applied."""
496
+ try:
497
+ import uvicorn
498
+ except ModuleNotFoundError:
499
+ _fail("serving needs FastAPI and uvicorn -- pip install 'brainpatch[server]'")
500
+
501
+ from brainpatch.server.app import build_app
502
+
503
+ model_obj, _ = _load_model_with_patches(model, patch, backend, device, strength, mode)
504
+ console.print(f"[green]serving[/green] http://{host}:{port}/v1 (backend: {backend})")
505
+ console.print(f" patches: {', '.join(model_obj.list_patches()) or 'none'}")
506
+ uvicorn.run(build_app(model_obj), host=host, port=port, log_level="info")
507
+
508
+
509
+ @app.command()
510
+ def ui(
511
+ model: Optional[str] = typer.Option(None, "--model", "-m"),
512
+ backend: str = typer.Option("auto", "--backend", "-b"),
513
+ device: str = typer.Option("auto", "--device"),
514
+ host: str = typer.Option("127.0.0.1", "--host"),
515
+ port: int = typer.Option(7860, "--port"),
516
+ share: bool = typer.Option(False, "--share", help="Expose a public Gradio link."),
517
+ ) -> None:
518
+ """Launch the local web UI. Runs entirely on your machine."""
519
+ try:
520
+ from brainpatch.ui.app import launch
521
+ except ModuleNotFoundError:
522
+ _fail("the UI needs Gradio -- pip install 'brainpatch[ui]'")
523
+ launch(model=model, backend=backend, device=device, host=host, port=port, share=share)
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # environment
528
+ # ---------------------------------------------------------------------------
529
+
530
+
531
+ @app.command()
532
+ def doctor(json_output: bool = typer.Option(False, "--json")) -> None:
533
+ """Report which inference engines are installed and usable."""
534
+ from brainpatch.runtime.auto import environment_report
535
+
536
+ report = environment_report()
537
+ if json_output:
538
+ console.print_json(json.dumps(report))
539
+ return
540
+
541
+ console.print(f"[bold]BrainPatch {report['brainpatch_version']}[/bold]")
542
+ console.print(f" Python {report['python']}")
543
+ console.print(f" Platform {report['platform']}")
544
+ console.print(f" Registry {report['registry_home']}")
545
+ console.print(f" Patches {len(report['installed_patches'])} installed")
546
+ console.print()
547
+
548
+ table = Table(title="Backends")
549
+ table.add_column("backend")
550
+ table.add_column("status")
551
+ table.add_column("detail")
552
+ for entry in report["backends"]:
553
+ ok = entry["available"]
554
+ table.add_row(
555
+ entry["backend"],
556
+ "[green]available[/green]" if ok else "[red]unavailable[/red]",
557
+ entry["detail"],
558
+ )
559
+ console.print(table)
560
+
561
+ if not any(e["available"] for e in report["backends"]):
562
+ console.print(
563
+ "\n[yellow]No backend available.[/yellow] Install one:\n"
564
+ " pip install 'brainpatch[transformers]'"
565
+ )
566
+
567
+
568
+ @app.command()
569
+ def backends(json_output: bool = typer.Option(False, "--json")) -> None:
570
+ """Show the capability matrix for every backend."""
571
+ from brainpatch.runtime.auto import available_backends
572
+ from brainpatch.runtime.capabilities import CAPABILITY_FLAGS
573
+
574
+ statuses = available_backends()
575
+ if json_output:
576
+ console.print_json(json.dumps([s.to_dict() for s in statuses]))
577
+ return
578
+
579
+ table = Table(title="Backend capabilities")
580
+ table.add_column("capability")
581
+ for status in statuses:
582
+ table.add_column(status.name, justify="center")
583
+
584
+ for flag in CAPABILITY_FLAGS:
585
+ row = [flag]
586
+ for status in statuses:
587
+ caps = status.capabilities
588
+ if caps is None:
589
+ row.append("?")
590
+ else:
591
+ row.append("[green]yes[/green]" if caps.supports(flag) else "[dim]no[/dim]")
592
+ table.add_row(*row)
593
+ console.print(table)
594
+
595
+ console.print("\n[dim]Notes on unsupported capabilities:[/dim]")
596
+ for status in statuses:
597
+ if status.capabilities and status.capabilities.notes:
598
+ console.print(f"\n[bold]{status.name}[/bold]")
599
+ for flag, note in status.capabilities.notes.items():
600
+ console.print(f" {flag}: {note}")
601
+
602
+
603
+ @app.command()
604
+ def benchmark(
605
+ model: str = typer.Option(..., "--model", "-m"),
606
+ patch: list[str] = typer.Option([], "--patch", "-p"),
607
+ backend: str = typer.Option("auto", "--backend", "-b"),
608
+ device: str = typer.Option("auto", "--device"),
609
+ max_new_tokens: int = typer.Option(128, "--max-tokens"),
610
+ runs: int = typer.Option(3, "--runs"),
611
+ prompt: str = typer.Option("Explain how a bicycle works.", "--prompt"),
612
+ ) -> None:
613
+ """Measure patched vs unpatched throughput and patch load time."""
614
+ import time
615
+
616
+ from brainpatch.runtime.base import GenerationConfig
617
+
618
+ model_obj, _ = _load_model_with_patches(model, patch, backend, device, None, "strict")
619
+ cfg = GenerationConfig(max_new_tokens=max_new_tokens)
620
+
621
+ def timed(enabled: bool) -> tuple[float, int]:
622
+ for name in model_obj.list_patches():
623
+ model_obj.backend.set_enabled(name, enabled)
624
+ durations, tokens = [], 0
625
+ for _ in range(runs):
626
+ start = time.perf_counter()
627
+ text = model_obj.generate(prompt, cfg)
628
+ durations.append(time.perf_counter() - start)
629
+ tokens = max(tokens, len(text.split()))
630
+ return sum(durations) / len(durations), tokens
631
+
632
+ base_time, _ = timed(False)
633
+ patch_time, _ = timed(True)
634
+
635
+ table = Table(title=f"Throughput ({runs} runs, {max_new_tokens} max tokens)")
636
+ table.add_column("condition")
637
+ table.add_column("mean seconds", justify="right")
638
+ table.add_column("tokens/sec", justify="right")
639
+ table.add_row("baseline", f"{base_time:.3f}", f"{max_new_tokens / base_time:.1f}")
640
+ table.add_row("patched", f"{patch_time:.3f}", f"{max_new_tokens / patch_time:.1f}")
641
+ console.print(table)
642
+ overhead = (patch_time - base_time) / base_time * 100 if base_time else 0.0
643
+ console.print(f"\noverhead: {overhead:+.1f}%")
644
+ console.print(
645
+ "[dim]Wall-clock over few runs; treat small differences as noise.[/dim]"
646
+ )
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # compilation
651
+ # ---------------------------------------------------------------------------
652
+
653
+
654
+ @app.command()
655
+ def compile(
656
+ source: str = typer.Argument(..., help="Research patch .json, or a .brainpatch to re-export."),
657
+ output: str = typer.Option(..., "--output", "-o"),
658
+ sae: Optional[str] = typer.Option(None, "--sae", help="SAE checkpoint the feature IDs index into."),
659
+ backend: Optional[str] = typer.Option(None, "--backend", help="Export for a backend, e.g. llama.cpp"),
660
+ strength: float = typer.Option(1.0, "--strength", help="Scale baked into a backend export."),
661
+ force: bool = typer.Option(False, "--force"),
662
+ ) -> None:
663
+ """Compile a research patch into a self-contained runtime artifact.
664
+
665
+ Without --backend this produces a portable .brainpatch. With --backend it
666
+ exports a backend-specific representation (currently llama.cpp control
667
+ vectors) from an already-compiled .brainpatch.
668
+ """
669
+ src = Path(source)
670
+
671
+ if backend:
672
+ normalized = backend.lower().replace(".", "").replace("_", "")
673
+ if normalized not in {"llamacpp", "llama"}:
674
+ _fail(f"no exporter for backend {backend!r}; supported: llama.cpp")
675
+ try:
676
+ from brainpatch.patch.compiler import export_llamacpp_control_vector
677
+ except ModuleNotFoundError as exc:
678
+ _fail(f"export needs extra dependencies: {exc}")
679
+ try:
680
+ written = export_llamacpp_control_vector(src, output, strength=strength)
681
+ except Exception as exc: # noqa: BLE001
682
+ _fail(str(exc))
683
+ console.print(f"[green]exported[/green] {written} ({written.stat().st_size / 1024:.1f} KB)")
684
+ return
685
+
686
+ if sae is None:
687
+ _fail("compiling a research patch needs --sae pointing at its SAE checkpoint")
688
+
689
+ try:
690
+ from brainpatch.patch.compiler import compile_from_sae
691
+ from brainpatch.schemas.patch_io import load_patch as load_research_patch
692
+ except ModuleNotFoundError as exc:
693
+ _fail(f"compiling needs the research extra: {exc}")
694
+
695
+ try:
696
+ spec = load_research_patch(src)
697
+ written = compile_from_sae(spec, sae, output, overwrite=force)
698
+ except Exception as exc: # noqa: BLE001
699
+ _fail(str(exc))
700
+
701
+ from brainpatch.patch.loader import load_patch, patch_size_report
702
+
703
+ report = patch_size_report(load_patch(written))
704
+ console.print(f"[green]compiled[/green] {written}")
705
+ console.print(f" {report['archive_kb']} KB, {report['num_vectors']} vector(s), "
706
+ f"{report['hidden_size']}-d {report['dtype']}")
707
+
708
+
709
+ # ---------------------------------------------------------------------------
710
+ # research subcommands
711
+ # ---------------------------------------------------------------------------
712
+
713
+
714
+ @research_app.command("modal")
715
+ def research_modal(
716
+ function: str = typer.Argument(..., help="Modal function, e.g. gpu_info."),
717
+ extra: Optional[list[str]] = typer.Argument(None),
718
+ dry_run: bool = typer.Option(False, "--dry-run"),
719
+ ) -> None:
720
+ """Run a BrainPatch research job on Modal (this repo's research backend)."""
721
+ import shutil
722
+ import subprocess
723
+
724
+ binary = shutil.which("modal")
725
+ if binary is None:
726
+ _fail("the modal CLI is not installed -- pip install 'brainpatch[modal]'")
727
+ args = [binary, "run", f"modal_app/app.py::{function}", *(extra or [])]
728
+ console.print(f"[dim]$ {' '.join(args)}[/dim]")
729
+ if dry_run:
730
+ return
731
+ result = subprocess.run(args, check=False)
732
+ raise typer.Exit(code=result.returncode)
733
+
734
+
735
+ @research_app.command("contrast")
736
+ def research_contrast(
737
+ name: Optional[str] = typer.Argument(None, help="Contrast set name; omit to list."),
738
+ ) -> None:
739
+ """Inspect the synthetic behavioural contrast fixtures."""
740
+ from brainpatch.datasets import list_contrast_sets, load_contrast_set
741
+
742
+ if name is None:
743
+ console.print("available: " + (", ".join(list_contrast_sets()) or "none"))
744
+ return
745
+ try:
746
+ contrast_set = load_contrast_set(name)
747
+ except FileNotFoundError as exc:
748
+ _fail(str(exc))
749
+ console.print(f"[bold]{contrast_set.name}[/bold] -- {len(contrast_set)} examples")
750
+ console.print(contrast_set.description)
751
+ if contrast_set.synthetic:
752
+ console.print("\n[yellow]Synthetic development fixture, not a benchmark.[/yellow]")
753
+
754
+
755
+ # ---------------------------------------------------------------------------
756
+ # helpers
757
+ # ---------------------------------------------------------------------------
758
+
759
+
760
+ def _evidence_markup(level: str) -> str:
761
+ colour = {
762
+ "replicated": "green",
763
+ "controlled_interventional": "green",
764
+ "interventional": "yellow",
765
+ "predictive": "yellow",
766
+ "correlational": "yellow",
767
+ "none": "red",
768
+ }.get(level, "white")
769
+ return f"[{colour}]{level}[/{colour}]"
770
+
771
+
772
+ def _load_patch_arg(ref: str) -> Any:
773
+ from brainpatch.patch.loader import PatchLoadError, load_patch
774
+ from brainpatch.patch.registry import RegistryError, default_registry
775
+
776
+ try:
777
+ path = default_registry().resolve(ref)
778
+ except RegistryError as exc:
779
+ _fail(str(exc))
780
+ try:
781
+ return load_patch(path)
782
+ except PatchLoadError as exc:
783
+ _fail(str(exc))
784
+
785
+
786
+ def _load_model_with_patches(
787
+ model: str,
788
+ patches: list[str],
789
+ backend: str,
790
+ device: str,
791
+ strength: float | None,
792
+ mode: str,
793
+ ) -> tuple[Any, list[Any]]:
794
+ from brainpatch.runtime.auto import BackendNotAvailable
795
+ from brainpatch.runtime.model import BrainPatchedModel
796
+
797
+ try:
798
+ model_obj = BrainPatchedModel.from_pretrained(
799
+ model, backend=backend, device=device, compatibility_mode=mode # type: ignore[arg-type]
800
+ )
801
+ except BackendNotAvailable as exc:
802
+ _fail(str(exc))
803
+ except Exception as exc: # noqa: BLE001
804
+ _fail(f"could not load model {model!r}: {exc}")
805
+
806
+ handles = []
807
+ for ref in patches:
808
+ try:
809
+ handles.append(model_obj.install(ref, strength=strength))
810
+ except Exception as exc: # noqa: BLE001
811
+ _fail(f"could not install patch {ref!r}: {exc}")
812
+ return model_obj, handles
813
+
814
+
815
+ def main() -> None:
816
+ """Console-script entry point."""
817
+ try:
818
+ app()
819
+ except KeyboardInterrupt: # pragma: no cover
820
+ err_console.print("[yellow]interrupted[/yellow]")
821
+ sys.exit(130)
822
+
823
+
824
+ if __name__ == "__main__": # pragma: no cover
825
+ main()