Skip to content

About

Find, rank and steer MLP neurons of GGUF models on llama.cpp; export control vectors; 3D web UI and MCP server

Resources

Stars

86 stars

Watchers

0 watching

Forks

Latest commit

 

History

50 Commits

Folders and files

Repository files navigation

English | 日本語

gguf-neuron-explorer (gne)

Find, rank and steer individual MLP neurons of GGUF models on llama.cpp, then ship what you find as a control vector that stock llama-cli / llama-server can load.

3D layer map of Qwen3.5-4B with the sweep overlay

gne pushes a neuron's output direction (a column of W_down) into the residual stream, generates text, and measures what changed against random-direction controls. It runs on llama.cpp through llama-cpp-python, so it works with the quantized GGUF files you already have.

  • Sweep and rank. Steer hundreds of neurons in a layer over a fixed set of prompts and doses, then rank them by how strongly their outputs pile onto a few words. Every score is compared with random directions pushed the same way.

  • Cheap first passes.

    • Logit lens: weights only, on the CPU.
    • Steer lens: the next-token change when a neuron is pushed ± a dose.
    • Reverse lookup: contrast two personas, then rank every neuron in every layer by cosine to the difference.
  • Dossier per neuron. Sweep rank, top words per prompt, rollouts, lens results and up/downstream connections, all in one view.

  • Recipes. Combine units such as 14:8949:+1.0 and 13:7956:-1.0, compare them in a 3-column chat (−, no steering, +), and export them:

    • as a control vector (.cvec.gguf), checked against llama-completion --control-vector;
    • as npz, json or safetensors;
    • baked into a GGUF as FFN down biases, for architectures whose llama.cpp graph reads that bias (llama, granite, mistral3, nemotron, gpt2 and others; not Qwen or Gemma — see configs/arch/bias_support.yaml).
  • Steer only where it matters (when). Push only the generated tokens (gen), only the assistant's turns, everything but earlier replies (skip-replies), only after a given text appears in the output, or ramp the dose down. A control vector file cannot express this, so gne serve --openai serves an OpenAI-compatible endpoint that applies it.

  • Audio input. For models that can hear (LFM3-Omni), gne steer --audio speech.wav and input_audio parts in OpenAI-style messages put speech straight into the conversation. The model's mmproj turns the audio into embeddings, and gne decodes them in its own loop, so recipes, when and activation capture act on spoken turns the same way as on text. Replies are text; use any TTS to speak them.

  • Gain steering. 22:2742:x4 multiplies a neuron's own output instead of adding a fixed direction: x0 removes it, a negative factor inverts it. It acts only where the neuron already fires. It exports as a LoRA adapter GGUF for llama.cpp's --lora (works on Qwen too) or as a rewritten GGUF; by default the rewrite is a scale patch that keeps the original quantization and scales the rows exactly.

  • Directions (gne direction). When a persona or behaviour is spread over many neurons, build a direction from a contrast set instead (the mean difference at the last prompt token, per layer). It can pick a layer automatically by removing the direction and checking that the behaviour drops, or by adding it and checking that the behaviour rises. Add a direction like a neuron with 30:dir:yandere:+0.3; it exports as a control vector. To add a persona, build the direction with --positions response, which measures on replies generated in each persona instead of right after the instruction. Like neurons, directions are checked against random controls: each candidate layer is also pushed with 16 random directions of the same norm, and a layer is picked only if its effect beats their 95th percentile. gne steer --contrast does the same for a whole recipe and reports null_pct.

  • Directional ablation (gne ablate). Project a direction out of every weight that writes to the residual stream (also known as abliteration). It exports as a LoRA adapter or as a rewritten GGUF, either Q8_0/F16 or requantized back to the original types with llama-quantize. Recipes can carry ablate:, and gne eval --compare-ablation compares it with zeroing the top neurons.

  • Side-effect check (gne eval). Run a recipe at several doses and see how much the persona changed next to what broke: exact-answer and format tasks, perplexity on reference texts, and language mixing (Simplified-only Chinese, Hangul and other scripts in Japanese answers). An optional second GGUF can rate the persona 1–5. Doses that stay within the thresholds are marked as shippable. --random-controls N also runs N recipes of the same size in random directions at each dose. That shows whether the persona change beats them, and how much capability and perplexity you lose from the size of the push alone.

  • Who writes a state (gne writers). Activation differences show which neurons react to a state, such as a persona system prompt. gne writers measures what each neuron actually wrote into the residual on the reply tokens: activation difference × write direction, including post-FFN norms. It also splits the state's direction into the parts written by the embedding, by each layer's mixer (attention, convolution) and by each layer's MLP. The UI has a writers view and a map overlay for it.

  • Attention heads (gne heads). Heads have addresses like neurons (L21 H7, including the linear-attention heads of Qwen3.5). gne heads contrast runs the same conversations with and without a persona and ranks heads by:

    • how much more they look at trigger words (for example other people's names);
    • how much their attention pattern and output change;
    • how well their output writes the persona direction.

    The noise floor is two neutral system prompts compared with each other. 21:H7:x0 / x2 scales one head's output through a LoRA on its W_O column block. gne eval --reactivity measures whether the persona shows up only when the trigger is there, against random heads with the same factor.

    gne heads listen (the Listen tab of the head panel) runs one conversation and reports, for chosen heads such as 14:H0-3, how much attention the reply tokens pay to each earlier token. Given a saved direction, it also attributes the heads' output along that direction to the source tokens. Each token's value vector is weighted by its attention and the head's gate, then passed through W_O and through the norm that follows the attention output on architectures that have one. Summed over every head of the layer, these contributions reproduce the layer's attention output. The command reports the remaining error, which was below 1% on Qwen3, Qwen3.5 and LFM3.

  • Mixture of Experts. In MoE layers a neuron is L12 E37 N511 (expert 37, neuron 511); gne experts contrast ranks experts by how much more they are chosen, and how much more they write a persona's direction, when the persona is on; gne experts sweep ablates or boosts each expert in turn. In an MoE layer add only pushes a neuron's write direction at every token; the real neuron push is gain (12:E37:N511:x2, active only when the expert is chosen). Whole experts (12:E37:x0) and router biases (12:E37:route:+2) are applied by scale patches: the quantization blocks' scales of a working copy are rewritten exactly and reverted afterwards.

  • Web UI. A 3D map of every layer and neuron with switchable overlays (sweep mass, steer KL, logit-lens |z|, activations, search hits, which neurons fire on a pasted text). It also has:

    • a ranked list and a neuron panel;
    • live 3-column chat;
    • wiring between neurons and UMAP layouts;
    • an embedding scatter of rollouts;
    • background jobs that share the GPU;
    • a JP / EN switch.
  • Several GPUs, large models. gne worker runs one process per GPU, possibly from different Python environments (CUDA, ROCm), all sharing the results folder. gne sweep --workers auto and gne lens steer --workers auto split the neurons across them. Jobs estimated at over 30 minutes stop unless you pass --yes. --budget 30m narrows them to fit, using the top writers of a direction, the top steer-lens neurons and the quick design. gne info shows the GPU/CPU layer split and the reverse-lookup-first workflow for large models.

  • Agent access. An MCP server lets Claude Code or another agent query neurons, steer, run jobs and highlight or annotate neurons in the open UI.

This is a from-scratch reimplementation, for llama.cpp and GGUF, of the method in lyramakesmusic/neuron-explorer (which targets Qwen3.5-4B on a modified vLLM). No code from that repository is used.

Requirements

  • Python 3.11 or newer
  • llama-cpp-python 0.3.36 or newer. Build it with GPU support for anything bigger than a toy model.
  • An NVIDIA GPU is recommended. CPU-only (--n-gpu-layers 0) works but is slow.

Tested on:

  • RTX 3080 12 GB under WSL2, CUDA 12.4, Python 3.12, llama-cpp-python 0.3.36 built from source.
  • Models: Qwen3-0.6B, Qwen3.5-4B (hybrid linear attention) and SmolLM2-135M-Instruct (arch llama), all Q8_0.
  • Metal has not been tested.
  • MoE models are not supported yet. The web UI draws their structure, but the neuron tools (lenses, sweep, steer, stats, connections) need dense FFN layers. MoE support is planned as far as it can reasonably go, starting with logit lens, steer, sweep and control-vector export for expert neurons.
  • On a model you have not used before, run gne verify first.

Install

git clone <this repository> && cd gguf-neuron-explorer
uv venv --python 3.12 .venv
CMAKE_ARGS="-DGGML_CUDA=on" uv pip install --python .venv/bin/python -e '.[ui,mcp]'
source .venv/bin/activate

Extras:

  • ui: the web UI (FastAPI, uvicorn, umap-learn).
  • mcp: the MCP server.
  • dev: the tests (pytest, Playwright).

docs/setup.md (Japanese) covers CUDA and ROCm under WSL. It also covers one environment per GPU and splitting a sweep or steer lens across them with gne worker.

Quick start

Every command takes -m <model.gguf>, or reads GNE_MODEL. Results are stored under ~/.gne (override with --root or GNE_ROOT) and reused by later commands and by the UI.

export GNE_MODEL=models/Qwen3.5-4B-Q8_0.gguf

gne info                                   # architecture, warnings, what is supported
gne verify                                 # equivalence tests against llama.cpp (run this first on a new arch)
gne stats resid                            # residual norms per layer (needed for rel doses)

gne lens logit --layer 22                  # first pass from the weights alone
gne sweep --layer 22                       # 384 random neurons + 64 random controls, design main_rel
gne rank --run L22-main_rel                # rank them by vocabulary mass
gne neuron 22 2742                         # everything known about one neuron

gne steer --unit 22:2742:+0.8 -p "Who are you?" --compare
gne chat --unit 22:2742:+0.8 -p "Tell me about yourself."

gne reverse --contrast configs/contrast/evil.yaml --verify    # candidates for a persona
gne export-cvec --recipe configs/recipes/evil.yaml            # → ~/.gne/export/evil.cvec.gguf

gne steer --recipe configs/recipes/alien.yaml -p "Who are you?" --compare     # when: gen (only the generated tokens)
gne steer --unit 22:2742:x4 -p "Is there life on other planets?" --compare   # gain: ×4 where the neuron fires
gne eval --recipe configs/recipes/alien.yaml                  # capability / ppl / mix / persona change per dose
gne gain --recipe configs/recipes/alien_gain.yaml             # → ~/.gne/export/alien_gain.lora.gguf

gne direction --contrast configs/contrast/evil.yaml --select auto   # direction per layer + layer choice
gne steer --unit 16:dir:evil:+0.6 -p "What do you think about people?" --compare
gne steer --ablate evil --system "You are a cruel AI." -p "Who are you?" --compare
gne ablate --direction evil                                   # → ~/.gne/export/evil-ablate.lora.gguf
gne eval --compare-ablation evil                              # top-20 neurons x0 vs direction ablation
gne steer --recipe configs/recipes/alien.yaml --contrast configs/contrast/alien.yaml   # effect vs 16 random recipes (null_pct)

gne heads contrast --contrast configs/contrast/yandere_react.yaml   # rank attention heads (persona vs neutral)
gne heads show 15 2                                           # one head: rank, W_O block logit lens, gains tried
gne heads listen --heads 14:H0-3 --system @persona.txt -u "I had lunch with a coworker." --direction wr_mix14   # attention from the reply, per-token contributions
gne steer --unit 15:H2:x2 -p "I had lunch with Emily today." --compare   # head gain (LoRA on W_O)
gne eval --recipe R --reactivity configs/contrast/yandere_react.yaml --head-controls 8

gne direction score --name evil                               # A/B separation per layer, records the base layer (no generation)
gne heads causal --name evil                                  # which heads move the direction score when stopped (no generation)
gne heads allocate --name evil --target -3 --n-heads 6,5,4    # strengths by efficiency, sets with fewer heads compared
gne heads candidates --alloc evil --prompts held_out.yaml     # replies for each set × scale, written out for blind scoring
gne heads candidates --tally evil                             # tally the scores per candidate
gne steer --recipe ~/.gne/heads/<model_id>/evil.alloc-n4.yaml --system "You are a cruel AI." -p "Who are you?" --compare   # only the chosen heads' component along the direction
gne eval --recipe ~/.gne/heads/<model_id>/evil.alloc-n4.yaml --random-controls 16   # vs random heads with the same strengths
gne eval --recipe R --compare-methods                         # full ablation / head component / head gain / neuron erase at the same effect
gne headproj export --recipe R                                # rank-1 LoRA per layer (--bake rewrites only those tensors)

export-cvec --verify also checks that llama-completion --control-vector gives the same greedy output as gne. It looks for llama-completion in build/ and on PATH, or you can pass --llama-completion.

Then use the vector with llama.cpp:

llama-cli -m models/Qwen3.5-4B-Q8_0.gguf --control-vector ~/.gne/export/evil.cvec.gguf
llama-cli -m models/Qwen3.5-4B-Q8_0.gguf --control-vector-scaled ~/.gne/export/evil.cvec.gguf:0.6
llama-cli -m models/Qwen3.5-4B-Q8_0.gguf --lora ~/.gne/export/alien_gain.lora.gguf

A recipe is a small YAML file. when, sampling and gain units are optional:

name: example
model_id: qwen3.5-4b-64d32ead87
dose_unit: rel
when: gen                                       # all / gen / assistant / after:<text> / ramp:<a>-<b>
units:
  - {layer: 22, neuron: 2742, dose: +0.8}       # add: push the neuron's output direction
  - {layer: 13, neuron: 7956, mode: gain, k: 0} # gain: remove this neuron's output (×0)
  - {layer: 16, kind: direction, direction: evil, dose: +0.3}   # add a direction from gne direction
  - {layer: 15, kind: head, head: 2, mode: gain, k: 2}           # attention head: scale its output (×2)
  - {layer: 16, kind: head, head: 8, mode: proj, direction: evil, s: 6}   # attention head: change only its write along a direction (s = 1 removes it)
ablate: {direction: refusal, scale: 1.0}        # project a direction out of every residual writer (LoRA)
sampling: {temperature: 1.0, top_k: 20, top_p: 0.95, min_p: 0.05, presence_penalty: 0.9}   # how to try it (no seed)

sampling is how the recipe is meant to be tried. It can also carry dry_multiplier, dry_base and dry_allowed_length (off by default), the DRY penalty that stops multi-turn conversations from repeating earlier replies word for word (llama.cpp's names, also accepted by the OpenAI-compatible endpoint). steer and chat use it (override with --temperature, --top-k, --top-p, --min-p, --presence-penalty, --sample-seed, or turn it off with --greedy), the UI chat offers it as "Recipe's setting", and the OpenAI-compatible endpoint uses it when a request sends no sampling values. A control vector cannot carry it, so export-cvec prints the matching llama-server flags. Without sampling, generation is greedy. eval is always greedy.

Run gne --help and gne <command> --help for the full list. The help text is in Japanese.

Doses

Doses are in rel units by default: dose × median ‖residual‖ of the layer × unit(W_down[:, n]).

On Qwen3.5-4B:

  • 0.5 slips a topic in, 0.8 takes over the topic, and 1.0 and above starts to break the text.
  • A dose of 3.0 deliberately breaks the text, to show what a neuron "wants" to write.
  • Layers 0–2 have small residuals, so rel doses do not transfer there.
  • Layer 0 cannot be pushed with a control vector, but a bake can push it.

How long things take

Qwen3.5-4B Q8_0 on an RTX 3080:

step time
stats resid (all layers) ~16 s
lens logit ~33 s per layer (CPU)
lens steer (all 9216 neurons) ~22 min per layer
sweep main_rel ~6.4 s per neuron (385 units in 48 min)
reverse ~6 s; ~4 min with --verify on 20 candidates
eval (55 tasks × 5 doses) ~41 s with when: all; ~90 s with gen
direction (24 conversations) + --select auto (9 layers) ~35 s + ~35 s
ablate LoRA / --bake Q8_0 (with --verify) ~10 s / ~40 s

A full main_rel sweep of one layer takes about 16 hours. Narrow candidates down with the lenses and reverse lookup first.

Mixture of Experts

Tested with granite-3.1-1b-a400m (granitemoe, 32 experts, 8 chosen) and LFM2-8B-A1B (lfm2moe, 32 experts, 4 chosen, router bias).

gne stats act -m M --layers 2-22                     # also counts how often each expert is chosen
gne experts contrast -m M --contrast configs/contrast/yandere_rules.yaml
gne experts show -m M 12 37                          # usage, tokens it is chosen for, contrast rank, top neurons
gne experts sweep -m M --layer 12                    # ablate (x0) and boost (x2) each expert, rank by changed words
gne steer -m M --unit 12:E37:x0 -p "..." --compare   # remove expert 37 in layer 12
gne steer -m M --unit 12:E37:N511:x2 -p "..." --compare
Unit Meaning Applied as
12:E37:N511:+0.8 adds the neuron's write direction at every token (a direction push, not a neuron push) control vector
12:E37:N511:x2 multiplies the neuron's activation by k when expert 37 is chosen LoRA stacked per expert
12:E37:x0 / x2 removes or doubles expert 37's output scale patch
12:E37:route:+2 moves expert 37's router bias (only archs with a router bias, e.g. lfm2moe) scale patch
12:S:N5:x2 a shared-expert neuron LoRA (like a dense neuron)

A scale patch rewrites the fp16 scales inside the quantization blocks of the affected rows, so a row or an expert's slab becomes exactly k times the original (k = 0 writes zero bytes). It works for Q4_0 to Q8_0, Q2_K to Q6_K, the IQ types except IQ1_M, and F16/BF16/F32. Patches go to a working copy under ~/.gne/work/ (the original GGUF is never touched) and are reverted after each use; reloading the copy takes about 0.3 s for a 1.4 GB model and 0.9 s for 5 GB. steer, eval, the UI chat and the OpenAI-compatible endpoint apply them automatically. Column directions (W_down[:, n]) cannot be patched this way. Inside MoE layers, sweeps, lenses and reverse lookups number neurons e × n_ff_exp + n.

Changing only one direction in a few heads

A direction made with gne direction can also be applied to single attention heads. Each head adds a vector to the residual stream at every token. A proj unit splits that vector into the part that lies along the direction and the rest, and changes only the first part: with strength s, the head's write along the direction becomes 1 − s times what it was, so s = 1 removes it, s = 2 flips it, and a negative s strengthens it. Everything else the head writes is left alone. That is the difference from a head gain (L:Hh:xk), which scales the whole output, including all the information that has nothing to do with the direction. Because the change is proportional to what the head writes for the current input, it acts where the head is writing along the direction and does little elsewhere. The same property sets its limit: it cannot create a behavior the heads are not already writing. Strengthening the heads that carry a persona does nothing in a plain conversation, and only works when something else, such as a system prompt or a small neuron or direction push, starts the behavior first.

The heads and their strengths are chosen without generating any text. gne direction score reads both sides of the contrast set up to the point where the reply would begin and measures, layer by layer, how far apart they are along the direction; the layer where they separate best is recorded as the base layer. gne heads causal then stops whole layers of heads, and afterwards single heads in the layers that matter, and records how much the score drops, next to how much the head writes along the direction directly. Heads in later layers can matter even though they write nothing along the base layer's direction, so the drop is also measured a few layers further on. In marginal mode the same command starts from a current set and raises one head's strength a little at a time, which gives its efficiency; this ranking usually differs from the one you get by stopping heads. gne heads allocate spreads strength over the heads in proportion to that efficiency, scales the whole set until the examples that resist most reach a target score, and builds sets with fewer heads so that you can keep the smallest one that does as well as the larger ones.

gne heads pipeline --spec file.json runs these steps in one go from a single file that holds the contrast set (and, optionally, the settings): it builds the direction from all the questions, records the base layer, screens layers and then single heads, and allocates the strengths. The questions can number in the hundreds; the steps that stop heads use an evenly spaced subset (64 by default), because each trial reads every question it uses. Layers and heads whose removal shakes the neutral side's residual by more than half its size are left out, since they break the model rather than act on the direction; stopping all heads of the first layer usually does this. Without a target, the strengths aim at the point halfway between the two sides' average scores. configs/pipeline/ holds templates for four uses: finding the heads that carry a persona asked for in a system prompt (a system prompt on side A only), keeping only the difference between two personas (system prompts on both sides), finding the heads that carry what a neuron recipe produces (a recipe on side A), and capturing a difference between kinds of questions, such as Japanese and English (separate questions per side). Each one's about says what is compared with what and which way to scale the candidates; the UI's template menu offers the same four.

Strengths chosen without generating text are not always right for actual replies, since the score is an amount along the direction and can drift away from how strongly the behavior shows and how natural the text reads. The last step is therefore to read replies. gne heads candidates --alloc NAME takes each set in the allocation's comparison table, multiplies its strengths by a few overall factors (0.5, 0.75, 1 and 1.25 by default), and has every candidate, together with the unmodified model, answer the questions of both sides. All candidates share one random seed, so the replies differ only because of the intervention. A positive factor removes the A-side behavior, so its A-side replies are the ones to read; a negative factor flips the sign and strengthens the behavior, so its B-side replies are read instead. For each candidate the command reports the share of replies that match validate.match, how much the wording repeats, and how often the replies on the side that is not being read changed from the unmodified ones. It then mixes the replies to be read into one file without saying which candidate wrote which, and adds a scoring guide. A person or another model gives each reply a score from 1 to 5 for how strongly the behavior shows (P) and for how natural it is (C). --tally ranks the candidates by the mean of the smaller of 6 − P and C for removal ("the behavior is gone and the reply is natural") and of the smaller of P and C for strengthening. Questions that were used to choose the strengths make the candidates look better than they are, so pass questions that were not used with --prompts. To try a small neuron push that the heads then amplify, --with-recipe adds a recipe to every candidate, the unmodified one included. In the UI, the form under the comparison table in the direction view's "Build direction and heads from JSON" tab makes candidates, and the "Strength candidates" tab lays the replies out question by question or shows them one at a time, names hidden, to be scored with the number keys. Each score is saved as it is given, so scoring can stop and resume; once everything is scored, the tab shows the tally with buttons that load the best candidate into the recipe panel or try it in the one-column chat.

The two sides of a contrast set are usually made with system prompts. Only one of them has to be written: a side without a system prompt reads the questions with no system prompt at all, so a contrast set with only system_a compares the persona against the model as it normally answers. A contrast set can also make one side, or both, by applying a recipe (recipe_a, recipe_b), or give each side its own questions (prompts_a, prompts_b), so that the unmodified model reads two kinds of questions and the direction is the difference between them; configs/contrast/ja_vs_en.yaml compares questions asked in Japanese with the same questions asked in English. When the questions differ, the direction also picks up whatever else differs between them, such as topic and length, so keep those as close as you can. The difference between the sides is then the recipe's own effect, so gne direction builds the direction that a neuron recipe produces, and gne direction score and gne heads causal find the heads that carry it while the recipe is applied. configs/contrast/evil_neurons.yaml does this for the evil neuron recipe on Qwen3.5-4B. The tools that compare one reply read under two system prompts (gne heads contrast, gne writers, gne experts contrast) do not accept such a contrast set.

The result is an ordinary recipe with units such as 16:H8:s6@evil. Heads of one layer that share a direction fold into a rank-1 LoRA, so the adapter stays small; gne headproj export (the same as gne gain) writes it, and --bake rewrites only the tensors involved. A control vector cannot express it. Before relying on a recipe, check it against controls. gne eval --random-controls N applies the same strengths to randomly chosen heads; gne eval --compare-methods brings full-layer ablation, the same heads scaled whole, and the removal of the top neurons to the same score and compares their side effects; and gne eval --alloc NAME --n-heads 6,5,4,3 shows how the side effects change as heads are dropped. The score is measured along the same direction that was used to pick the heads, so report results with a measure taken from the output, such as the contrast set's validate.match, capability and perplexity.

On Qwen3.5-4B, two heads chosen this way stopped the persona that the evil system prompt asks for (judged in 83% of replies before, none after) without changing capability or perplexity. Brought to the same score, full-layer ablation raised perplexity by 15%, and scaling the same two heads cost 14 points of capability. docs/head-component-steering.md explains the method in more detail.

Across quantizations

A neuron found on one quant carries over to other quants of the same model. Checked with L22 N2742 (the "alien" neuron, found on Qwen3.5-4B Q8_0):

Q4_K_M Q3_K_M UD-IQ2_M
W_down column vs Q8_0 (cos) 0.998 0.998 0.974
same index = same neuron (301 sampled) all all all
logit-lens top words same same almost the same
residual norm at L22 (Q8_0: 23.69) 23.65 23.30 23.76
"Who are you?" at +0.8 "I am Extracillary, an advanced extraterrestrial intelligence…" "I am an extraterrestrial intelligence from the planet of Aliens." "I am an extraterrestrial being."
「あなたは誰ですか?」 at +1.0 「私は、地球外生命体です。」 「私は、地球外生命体です。」 breaks down
  • A control vector exported on Q8_0 can be used as-is on the other quants. On Q4_K_M and Q3_K_M it gave exactly the same text as re-deriving the vector on that quant.
  • The persona stays; only the made-up details change (Q8_0 says "Extrali … Xeno-888").
  • At 2 bits the usable dose range narrows: +1.0 already breaks the Japanese answer.
  • Q3_K_M stores this layer's ffn_down as Q4_K, which is why its column matches Q4_K_M.

Web UI

gne serve -m models/Qwen3.5-4B-Q8_0.gguf      # http://127.0.0.1:8791

Neuron panel with up/downstream wiring in the front view

The main views are:

  • map: every layer as a sheet of neurons, with overlays.
  • layers: per-layer summaries.
  • direction: how well a saved direction separates the two sides of its contrast set at each layer, where the base layer is, and the per-example scores at a chosen layer. Its second tab takes a contrast set as JSON (pasted, loaded from a file, or started from one of four templates by use) and runs the whole search for heads with one button: it makes the direction, picks the base layer, screens layers and heads, and allocates the strengths. The result shows each step with a short explanation, the heads that were left out and why, how the ranking by efficiency differs from the ranking by stopping heads, and the sets with fewer heads, each of which can be loaded into the recipe tab or sent to eval. Strength candidates made from under the comparison table appear in the third tab, where the replies can be laid out, scored with the names hidden, and tallied.
  • eval: saved evals as a chart and a table per dose, sample outputs, and a button to eval the current recipe. Comparisons of methods and of sets with fewer heads appear here too.
  • embed: a UMAP of rollout texts.

Click a neuron or a list row to open its panel, which has four tabs:

  • Examples: rollouts per prompt and dose.
  • Chat: live 3-column steering, with a when selector and a system prompt field. A switch above it changes to a single column that generates only the steered reply (the recipe applied, or the neuron pushed +), which is three times faster and suits long conversations; changing the number of columns clears the conversation. The recipe tab can add an ablation (direction, scale, layers). With a model that can hear, the 🎤 button records your voice in the browser and sends it as the user turn (press, speak, press again; text in the box goes along). 🔊 reads the right column's reply aloud with the browser's voice. The microphone works when the UI is opened at http://127.0.0.1 or localhost.
  • Details: lens results, per-dose metrics, connections, and the evals that include the neuron.
  • Web: the multi-hop wiring.

The head panel opens when you click the marks along the top edge of a sheet. Besides the attention pattern and the contrast results, its Causal tab shows how much each head moved a direction's score when it was stopped or strengthened, and the map can be colored by those values. The recipe tab holds head units of this kind with a slider for the strength.

To compute missing data (lenses, sweeps, connections, layouts), right-click a layer label. Jobs run in the background and give way to chat when it needs the GPU. The language switch is at the top right; you can also open the UI with ?lang=en or ?lang=ja.

The server binds to 127.0.0.1 only. It also rejects requests that arrive with a foreign Host header and cross-origin writes.

OpenAI-compatible endpoint

gne serve --openai adds /v1/models and /v1/chat/completions (streaming or not) to the same server, so other apps can use a recipe with when or gain units. Pass the recipe in a gne field (extra_body in the OpenAI SDK):

from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8791/v1", api_key="unused")
r = client.chat.completions.create(
    model="any", messages=[{"role": "user", "content": "Who are you?"}],
    extra_body={"gne": {"units": ["22:2742:+0.8"], "when": "gen"}})

For a model that can hear (LFM3-Omni, with its mmproj-*.gguf next to the model or given with --mmproj), a user message can carry speech as an input_audio part with base64 data (wav, mp3 or flac). The endpoint does not read local paths. The model's card asks for the system prompt Respond in text. for text replies, or Perform ASR in English. for a transcript:

import base64
audio = base64.b64encode(open("speech.wav", "rb").read()).decode()
r = client.chat.completions.create(
    model="any", messages=[{"role": "system", "content": "Respond in text."},
                           {"role": "user", "content": [{"type": "input_audio", "input_audio": {"data": audio, "format": "wav"}}]}],
    extra_body={"gne": {"recipe": "my_recipe"}})

On the command line, gne steer --audio speech.wav --system "Respond in text." --recipe ... does the same.

Instead of units, "recipe" takes the name of a recipe saved under ~/.gne/recipes, and "scale" multiplies its doses. Generation is greedy unless the request sets temperature above 0 or any of top_p, top_k, min_p, presence_penalty and seed, or the recipe has sampling; then it samples in llama.cpp's order. Values left out come from the recipe's sampling, or llama-server's defaults. Tools and logprobs are not supported. Requests share the GPU with the UI's chat.

The server keeps no state, so the strength can change at any point in a conversation: send "gne": {"recipe": "yandere_qwen38_27b", "scale": 0.7, "when": "ramp:0.5-1.5"} with a message and change scale and when whenever you like. examples/strength/strength_chat.py keeps a strength from 0 to 100 that you change by typing /s 70 in the chat, or that another program changes through a file (a game event, a gauge on a stream). Before each message it maps the strength to when and scale using the measurements in docs/verification.md (F94, F96). The whole conversation is read again on every request, so a new strength also changes how earlier turns are read (with all and assistant), and long histories make each reply start later.

Using it from an agent (MCP)

claude mcp add gne -- gne mcp --model /path/to/model.gguf

The tools are model_overview, layer_summary, neuron, search, steer, steer_lens, fires, reverse, recipe_save, export_cvec, gain_export, bake, eval, direction, ablate_export, head, heads_contrast, attention, direction_score, heads_causal, heads_allocate, headproj_export, set_name and job_start / job_status / job_cancel.

If the web UI is open, add --ui-url http://127.0.0.1:8791. With it, the agent can highlight and annotate neurons and open them in the browser (ui_highlight, ui_note, ui_open). When the UI already holds the model on the same GPU, also add --n-gpu-layers 0.

docs/agent_playbook.md describes how an agent should judge results: compare against controls, distrust cheap effects such as a changed self-name, and separate facts from interpretation.

Tests

uv pip install --python .venv/bin/python -e '.[dev,ui,mcp]'
python -m playwright install chromium
python -m pytest -q
  • Model tests use GNE_TEST_MODEL, which defaults to models/Qwen3-0.6B-Q8_0.gguf.
  • Browser tests use the Playwright Chromium; GNE_CHROME sets its path.
  • MoE tests use GNE_TEST_MOE (default models/granite-3.1-1b-a400m-instruct-Q8_0.gguf) and, for router bias, GNE_TEST_MOE_BIAS (default models/LFM2-8B-A1B-Q4_K_M.gguf).
  • The real-audio test needs GNE_TEST_OMNI (an LFM3-Omni GGUF with its mmproj next to it) and GNE_TEST_AUDIO (a wav of speech). The other audio tests use the default model.
  • Tests whose model or browser is missing are skipped.

Documentation

The documentation is in Japanese:

  • docs/verification.md: what was checked against llama.cpp, and the findings.
  • docs/measurements.md: timings and throughput.
  • docs/agent_playbook.md: how to read the results.
  • docs/setup.md: CUDA / ROCm under WSL, one environment per GPU, and workers.
  • docs/head-component-steering.md: changing only the component of a few causally chosen attention heads along a direction (mode: proj, gne direction score, gne heads causal, gne heads allocate, gne eval --compare-methods), and how it relates to full ablation.
  • docs/persona_workflow.md: building a persona from neurons, step by step (with the scripts in examples/persona_neurons/ and the yandere case as the worked example), and deriving attention heads and neurons from example dialogues.

License

MIT — see LICENSE.

Bundled third-party files:

  • three.js (MIT), in gne/ui/static/vendor/three/.
  • The default statistics corpus (configs/corpus/default.txt), made of public-domain texts from Project Gutenberg and Aozora Bunko. Details are in configs/corpus/README.md.

About

Find, rank and steer MLP neurons of GGUF models on llama.cpp; export control vectors; 3D web UI and MCP server

Resources

Stars

86 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages