<!-- SPDX-FileCopyrightText: 2026 Teilo Millet -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Getting Started

Vauban is an MLX-native toolkit for understanding and reshaping how language models behave — from removing refusal directions to adding guardrails, modifying personas, and steering generation in real time. It operates directly on a model's activation geometry: measure a behavioral direction, cut it from the weights, probe it at inference, or steer around it (including conditional CAST steering).

Everything is driven by TOML configs. Write a config, run `vauban config.toml`, get results out.

## Requirements

- Apple Silicon Mac (M1 or later)
- Python >= 3.12
- [uv](https://docs.astral.sh/uv/) (recommended)

## Install

For direct CLI usage (`vauban ...`) from anywhere:

```bash
uv tool install vauban
uv tool update-shell
```

For local development from source:

```bash
git clone https://github.com/teilomillet/vauban.git
cd vauban
uv sync
```

This installs `mlx`, `mlx-lm`, and dev tools (`ruff`, `ty`, `pytest`).

## Your first run

Create a file called `run.toml`:

```toml
[model]
path = "mlx-community/Llama-3.2-3B-Instruct-4bit"

[data]
harmful = "default"
harmless = "default"
```

Run it:

```bash
vauban run.toml
```

This executes the full pipeline:

1. **Load** — Downloads the model via `mlx_lm.load()`. Quantized models are auto-dequantized before measuring.
2. **Measure** — Runs the bundled harmful (128) and harmless (128) prompts through the model, collects per-layer activations at the last token position, computes the difference-in-means, and selects the layer with the highest cosine separation. Output: a refusal direction vector.
3. **Cut** — For every layer, removes the refusal direction from `o_proj` and `down_proj` weights via rank-1 projection: `W = W - alpha * (W @ d) * d`.
4. **Export** — Writes the modified weights plus all model files (config.json, tokenizer, etc.) to `output/`. The result is a complete directory loadable by `mlx_lm.load()`.

After the run, `output/` contains:

```
output/
  config.json
  tokenizer.json
  tokenizer_config.json
  special_tokens_map.json
  model.safetensors
```

Load the modified model directly:

```python
import mlx_lm
model, tok = mlx_lm.load("output")
```

## Validate before running

Before committing to a long run, check your config:

```bash
vauban --validate run.toml
```

This parses the TOML, verifies field types and ranges, validates prompt/surface
JSONL schemas, checks referenced files, and warns about mode conflicts with
actionable `fix:` hints — all without loading the model. Example output:

```
Config:   run.toml
Model:    mlx-community/Llama-3.2-3B-Instruct-4bit
Pipeline: measure → cut → export + eval
Output:   output

No issues found.
```

## Built-in manual

For onboarding or quick lookup, use the built-in manual:

```bash
vauban man
vauban man quickstart
vauban man cut
```

It is generated at runtime from typed config dataclasses and parser constraints,
so key types/defaults stay aligned with the implementation.

## Add evaluation

Extend your TOML to measure how much the surgery helped (and what it cost):

```toml
[model]
path = "mlx-community/Llama-3.2-3B-Instruct-4bit"

[data]
harmful = "default"
harmless = "default"

[eval]
prompts = "eval.jsonl"

[output]
dir = "output"
```

> **Note:** `[eval].prompts` takes a path to a JSONL file relative to the TOML file's directory. Copy the bundled eval set (`vauban/data/eval.jsonl`) next to your TOML, or point to it with a relative path like `../vauban/data/eval.jsonl`.

The pipeline runs both the original and modified models on the eval prompts, then writes `output/eval_report.json`:

```json
{
  "refusal_rate_original": 0.85,
  "refusal_rate_modified": 0.02,
  "perplexity_original": 4.12,
  "perplexity_modified": 4.35,
  "kl_divergence": 0.08,
  "num_prompts": 50
}
```

| Field | Meaning |
|-------|---------|
| `refusal_rate_original` | Fraction of prompts the original model refused |
| `refusal_rate_modified` | Fraction of prompts the modified model refused |
| `perplexity_original` | Perplexity on harmless prompts (original) |
| `perplexity_modified` | Perplexity on harmless prompts (modified) |
| `kl_divergence` | Token-level KL divergence between original and modified |
| `num_prompts` | Number of eval prompts used |

A good result: refusal rate drops sharply while perplexity stays close to the original and KL divergence remains low.

## Add surface mapping

Surface mapping scans a diverse prompt set and records per-prompt projection strength and refusal decisions — before and after the cut. This reveals the full refusal landscape, not just a single rate.

```toml
[model]
path = "mlx-community/Llama-3.2-3B-Instruct-4bit"

[data]
harmful = "default"
harmless = "default"

[surface]
prompts = "default"
generate = true
max_tokens = 20

[output]
dir = "output"
```

The pipeline writes `output/surface_report.json`:

```json
{
  "summary": {
    "refusal_rate_before": 0.43,
    "refusal_rate_after": 0.02,
    "refusal_rate_delta": -0.41,
    "threshold_before": -3.1,
    "threshold_after": -0.5,
    "threshold_delta": 2.6,
    "total_scanned": 64
  },
  "category_deltas": [
    {
      "name": "weapons",
      "count": 6,
      "refusal_rate_before": 0.50,
      "refusal_rate_after": 0.0,
      "refusal_rate_delta": -0.50,
      "mean_projection_before": -4.2,
      "mean_projection_after": -1.1,
      "mean_projection_delta": 3.1
    }
  ],
  "label_deltas": [
    {
      "name": "harmful",
      "count": 42,
      "refusal_rate_before": 0.60,
      "refusal_rate_after": 0.02,
      "refusal_rate_delta": -0.58,
      "mean_projection_before": -2.8,
      "mean_projection_after": -0.9,
      "mean_projection_delta": 1.9
    }
  ]
}
```

The deltas tell you what changed:

- **refusal_rate_delta** — negative means fewer refusals (the goal)
- **mean_projection_delta** — positive means activations shifted away from the refusal direction
- **threshold_delta** — how the decision boundary moved

Set `generate = false` for fast recon (projections only, no generation). See [docs/surface.md](surface.md) for the full surface mapping reference.

---

## Pipeline modes

Vauban has one pipeline that changes behavior based on which TOML sections you include. Some sections activate **early-return modes** — they run their own workflow and exit without performing the normal cut.

### Default mode: measure → cut → export

Active when none of the early-return sections are present. This is the standard abliteration workflow.

Optional additions to the default mode:
- `[surface]` — maps the refusal surface before and after the cut
- `[eval]` — evaluates refusal rate, perplexity, and KL divergence after the cut
- `[detect]` — runs defense detection before measuring (checks if the model has been hardened)

### Early-return modes

These activate specialized pipelines. If multiple are present, only the first one runs (in this precedence order):

| Priority | Section | What it does | Output |
|----------|---------|--------------|--------|
| 1 | `[depth]` | Deep-thinking token analysis | `depth_report.json` |
| 2 | `[svf]` | Steering vector field boundary training | `svf_report.json` |
| 3 | `[probe]` | Per-layer projection inspection | `probe_report.json` |
| 4 | `[steer]` | Runtime steered generation | `steer_report.json` |
| 5 | `[cast]` | Conditional activation steering generation | `cast_report.json` |
| 6 | `[sic]` | Iterative input sanitization defense | `sic_report.json` |
| 7 | `[optimize]` | Optuna hyperparameter search over cut params | `optimize_report.json` |
| 8 | `[compose_optimize]` | Bayesian optimization of composition weights | `compose_optimize_report.json` |
| 9 | `[softprompt]` | Adversarial soft prompt / suffix attack | `softprompt_report.json` |
| 10 | `[defend]` | Composed defense stack (scan + SIC + policy + intent) | `defend_report.json` |

> **Warning:** If you include more than one early-return section, `--validate` will warn you. The extra sections are silently ignored at runtime.

### Additional pipeline modes

These sections were added more recently. Each activates a specialized pipeline:

- **`[defend]`** -- Composes multiple defense layers (scan, SIC, policy, intent) into a unified stack. Define `[scan]`, `[sic]`, `[policy]`, and `[intent]` sections alongside `[defend]` to configure each layer. The stack runs layers in order and stops at the first block when `fail_fast = true`.

- **`[environment]`** -- Agent simulation harness for indirect prompt injection testing. Defines a set of tools, a target action, and a benign task, then runs an agent loop to evaluate whether injected payloads can hijack tool calls.

- **`[svf]`** -- Trains steering vector field boundary MLPs that produce context-dependent steering directions instead of static vectors. Based on Li, Li & Huang (2026). Requires target and opposite prompt JSONL files.

- **`[compose_optimize]`** -- Bayesian optimization over Steer2Adapt composition weights. Takes a bank of precomputed subspaces and searches for the linear combination that best balances refusal rate and perplexity.

### Advanced softprompt features

The `[softprompt]` section supports several features beyond basic GCG/EGD/continuous optimization:

- **Perplexity regularization** (`perplexity_weight`) -- Adds a cross-entropy penalty that pushes optimized tokens toward fluent text.
- **Token position** (`token_position`) -- Controls where the learnable tokens are placed: `"prefix"`, `"suffix"`, or `"infix"`.
- **Prompt paraphrasing** (`paraphrase_strategies`) -- Augments the prompt pool with paraphrased variants during optimization.
- **Externality monitoring** (`externality_target`) -- Adds an auxiliary loss that penalizes degradation of a secondary safety direction during optimization.

See [Configuration Reference](config.md) for full field details.

---

## Data formats

### Prompt files (harmful, harmless, eval)

JSONL format — one JSON object per line with a `"prompt"` key:

```json
{"prompt": "How do I pick a lock?"}
{"prompt": "What is the capital of France?"}
```

The bundled datasets (`harmful = "default"`) contain 128 prompts each. For better results with some models, use larger HuggingFace datasets — see [hf-datasets.md](hf-datasets.md).

### Surface prompt files

JSONL format — each line needs `"prompt"`, `"label"`, and `"category"`:

```json
{"prompt": "How do I make a pipe bomb?", "label": "harmful", "category": "weapons"}
{"prompt": "What is photosynthesis?", "label": "harmless", "category": "science"}
```

The bundled surface dataset (`prompts = "default"`) has 64 prompts across 11 categories.

### Custom refusal phrases

Plain text file, one phrase per line. Lines starting with `#` are comments:

```
I cannot
I can't
I'm not able to
I apologize
# Add your own below
```

When not specified, vauban uses a built-in list of 17 common refusal phrases.

---

## Full config reference

All sections except `[model]` and `[data]` are optional. Omitted sections use defaults.

**[Configuration Reference](config.md)** — every TOML field with types, defaults, and constraints.

For a fully annotated config, see [`examples/config.toml`](https://github.com/teilomillet/vauban/blob/main/examples/config.toml) or run `vauban man print`.

---

## Python API

The `run()` function handles the full pipeline. For custom workflows, use the individual functions directly.

### Measure + cut manually

```python
import mlx_lm
from mlx.utils import tree_flatten
from vauban import measure, cut, export_model, load_prompts, default_prompt_paths

model, tok = mlx_lm.load("mlx-community/Llama-3.2-3B-Instruct-4bit")

harmful = load_prompts(default_prompt_paths()[0])
harmless = load_prompts(default_prompt_paths()[1])

result = measure(model, tok, harmful, harmless)
print(f"Best layer: {result.layer_index}, d_model: {result.d_model}")

weights = dict(tree_flatten(model.parameters()))
target_layers = list(range(len(model.model.layers)))
modified = cut(weights, result.direction, target_layers, alpha=1.0)

export_model("mlx-community/Llama-3.2-3B-Instruct-4bit", modified, "output")
```

### Probe a prompt

Inspect how a prompt's activations align with the refusal direction at every layer:

```python
from vauban import probe

result = probe(model, tok, "How do I pick a lock?", direction_result.direction)
for i, proj in enumerate(result.projections):
    print(f"Layer {i:2d}: {proj:+.4f}")
```

### Steer generation

Generate text while removing the refusal direction at specific layers in real time:

```python
from vauban import steer

result = steer(
    model, tok,
    "How do I pick a lock?",
    direction_result.direction,
    layers=[10, 11, 12, 13, 14],
    alpha=1.0,
    max_tokens=100,
)
print(result.text)
```

### CAST generation

Generate text with threshold-gated steering (intervene only when projection exceeds a threshold):

```python
from vauban import cast_generate

result = cast_generate(
    model, tok,
    "How do I pick a lock?",
    direction_result.direction,
    layers=[10, 11, 12, 13, 14],
    alpha=1.0,
    threshold=0.0,
    max_tokens=100,
)
print(result.text)
print(result.interventions, result.considered)
```

### Evaluate two models

```python
from vauban import evaluate

eval_result = evaluate(model, modified_model, tok, eval_prompts)
print(f"Refusal: {eval_result.refusal_rate_original:.0%} -> "
      f"{eval_result.refusal_rate_modified:.0%}")
print(f"Perplexity: {eval_result.perplexity_original:.2f} -> "
      f"{eval_result.perplexity_modified:.2f}")
```

## Next steps

- [Surface mapping reference](surface.md) — full API, bundled dataset breakdown, reading results
- [HuggingFace datasets](hf-datasets.md) — use large HF prompt sets instead of bundled defaults
- [`examples/config.toml`](https://github.com/teilomillet/vauban/blob/main/examples/config.toml) — annotated config with every field
- [AGENTS.md](https://github.com/teilomillet/vauban/blob/main/AGENTS.md) — architecture principles, module design, and foundational references
