Codync
무료 오픈소스 1:1 대안, Grok Bot과 Muse 대신
Find, rank and steer MLP neurons of GGUF models on llama.cpp; export control vectors; 3D web UI and MCP server
개발 도구GitHub @balaena01직접 설치운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗English | 日本語
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.
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.
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:
.cvec.gguf), checked against llama-completion --control-vector;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:
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:
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.
--n-gpu-layers 0) works but is slow.Tested on:
llama), all Q8_0.gne verify first.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.
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 fieldtest -m baked.gguf --against original.gguf # long tasks, tool calling, perplexity under real use, file against file
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 are in rel units by default: dose × median ‖residual‖ of the layer × unit(W_down[:, n]).
On Qwen3.5-4B:
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.
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.
The method described here is called CHiPS (Causal Head-isolated Projection Steering). As the name says, it applies a projection along a direction only within heads chosen by their causal effect. That restriction is what sets it apart from full ablation, which removes the direction from every writer in every layer.
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. Halfway often removes only about half of the behavior, so write B as the target to remove it fully: the strengths then aim at the B side's average (if the strongest head sticks at the cap s_max, 30 by default, raise it). If you stop a run and start it again with the same file and settings, it reuses the direction and the causal results it already built and starts again from the allocation (--fresh, or "rebuild from scratch" in the UI, builds everything again). The "remaining" column of the comparison table counts the A-side questions that a set does not bring down to the target. gne heads refine --name NAME ("add from remaining" in the table) takes only those questions as side A and the original B-side questions as side B, applies the set on both sides, rebuilds the direction, and runs the same steps to choose heads and strengths to add. Whatever the set already changes is the same on both sides, so the new direction keeps only the difference the set has not removed. The added heads were found with the original set applied, so they are never used alone: for every set in the comparison table it writes a recipe that combines the original set with that set, and measures the remaining count and the drift on the neutral side again along the original direction. The table's load and eval buttons use these combined recipes. These steps only read questions and never generate, so the context that measures the model's internals is sized to the longest conversation, and the generation context left over from chatting is released first; on a large model this keeps VRAM from overflowing into system memory, which makes everything slow. 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, 1, 1.5, 2 and 3 by default; the default target of the allocation only goes halfway, so the useful factor is often above 1), 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.
A question can be a single string or a conversation history: [{"role": "user", "content": "…"}, {"role": "assistant", "content": "…"}, {"role": "user", "content": "…"}], alternating user and assistant and ending with a user turn. Every question is read on its own from a clean state and measured just before the reply to its last user turn; nothing from one question carries over to the next. Use histories to measure behavior that appears only deep in a conversation or after a few exchanges.
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. In the UI, "LoRA / bake" in the recipe tab does the same, with the format, the type of the rewritten tensors, requantizing back to the original type, a check against the LoRA, and the file name (the recipe name by default; an existing file is only overwritten after you confirm) as options. Going back to low-bit types such as IQ1, IQ2 and IQ3_XXS needs an imatrix (the weights that tell the quantizer which inputs to keep accurate); put <model name>.imatrix.gguf (or .dat) next to the model and it is used without being passed. Baking does not use the GPU (it reads and rewrites the file piece by piece), so it works for models too large for the GPU: you can analyze with a quantized model and bake into an unquantized GGUF (F16 / BF16) of the same model (--into, "bake into" in the UI). The directions come from the analyzed model, the rewritten matrices from the target, and the type stays as it was (--requant keep). The strengths were measured on the quantized model, so expect small differences, and there is no check against the LoRA. Quantizing the baked F16 / BF16 afterwards with llama-quantize also avoids having only the rewritten tensors in a different type; a LoRA can be downloaded right away. gne records the recipe (layers, heads, strengths) in the exported file as gne.* metadata; for files you give to others, use "leave out gne's records" (--strip-meta), which also checks afterwards that no local paths remain in the metadata (when requantizing with an imatrix, llama-quantize may record the path of the text the imatrix was made from). Without the records, a LoRA's tensors still show the layers, heads and direction as they are, and a baked file compared with the original model still shows what was changed and how. 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. For a recipe that uses a direction, eval also measures the direction metrics (score drop, drift, match rate) at every scale and for every control; with contrast sets of hundreds of questions this is heavy, so it reads an evenly spaced 64 questions by default (, "questions" in the UI's eval tab) and can be switched off (, "direction metrics" in the UI).
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.
gne eval is a fast check: it scales a recipe on one file and asks short questions. gne fieldtest asks whether a model file holds up in real use, and compares files with each other. It starts llama-server with thinking and sampling on, gives the model long tasks and tool-calling tasks, and reports how often answers hit the token limit, how often they fall into short or long loops, how often the answer stays empty because the reasoning never finished, whether the right tool is called with complete arguments, whether no tool is called when none is needed, and whether the model carries on after a tool result. Perplexity comes from reading the same reference texts in gne, so a baked file can be measured directly, which gne eval cannot do.
gne fieldtest -m baked.gguf --against original.gguf --llama-server <path to llama-server>
gne fieldtest -m original.gguf --lora adapter.lora.gguf # with and without a LoRA
gne fieldtest -m baked.gguf --against original.gguf --prompts mine=questions.json --match "some regex"
The tasks are in configs/fieldtest/ and can be replaced with --long-tasks and --tool-tasks. --prompts name=file adds your own questions, measured for loops, empty answers and answers cut at the limit, and --match adds a match rate. Files are loaded onto the GPU one at a time. Differences of up to two questions, or 1% for perplexity, count as the same level, because one question can differ through sampling alone. Results go to <root>/fieldtest/<name>.json; the generated text goes to a separate <name>.replies.json and is never printed. On a 27B model (RTX 3080, 12 GB) one file took 30 to 45 minutes with 200 extra questions. gne does not build llama-server, so pass one with --llama-server or GNE_LLAMA_SERVER, or have it on PATH. In the UI, this is the "Field test" section of the eval view; while it runs, the loaded model leaves the GPU and chat has to wait.
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 |
ffn_down as Q4_K, which is why its column matches Q4_K_M.gne serve -m models/Qwen3.5-4B-Q8_0.gguf # http://127.0.0.1:8791
Press Ctrl+C in the terminal that started it to stop the server. It closes the open streams and any generation in progress and stops at once, even with the UI still open in a browser; if a connection still lingers, a second Ctrl+C cuts it. The page files are read fresh on every load, but the server keeps the Python code it started with, so restart it after updating gne; a notice at the top of the page says so when the server is older than the code.
The main views are:
Click a neuron or a list row to open its panel, which has four tabs:
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.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.
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.
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.
uv pip install --python .venv/bin/python -e '.[dev,ui,mcp]'
python -m playwright install chromium
python -m pytest -q
GNE_TEST_MODEL, which defaults to models/Qwen3-0.6B-Q8_0.gguf.GNE_CHROME sets its path.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).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.The documentation is in Japanese:
mode: proj, gne direction score, gne heads causal, gne heads allocate, gne eval --compare-methods), and how it relates to full ablation.examples/persona_neurons/ and the yandere case as the worked example), and deriving attention heads and neurons from example dialogues.MIT — see LICENSE.
Bundled third-party files:
gne/ui/static/vendor/three/.configs/corpus/default.txt), made of public-domain texts from Project Gutenberg and Aozora Bunko. Details are in configs/corpus/README.md.--dir-max-examples--no-direction-metrics