# Making your own vectors This is the whole pipeline that produced the files in `vectors/`: fit a vector to a fine-tune, score it to pick its strength, export it for llama.cpp, and (optionally) build a merge's vector from its parents' vectors. Every command here was run to make this release. ## What you need - **An Apple silicon Mac, for the code as shipped.** The fit and the score run on [MLX](https://github.com/ml-explore/mlx). They build one decoder layer at a time, so peak memory stays around 11 GB whatever the checkpoint size; measured on an M1 Max with 32 GB of unified memory. Nothing in the method needs MLX: it needs forward passes of both models over the same text, so it could be ported to PyTorch (NVIDIA or otherwise) or another engine. That port has not been written. - **Disk for two checkpoints**: stock `google/gemma-4-31B-it` and the fine-tune, about 62.5 GB each in bf16. A LoRA fine-tune can be fitted from its adapter instead, which is a few hundred MB to a few GB. - **[uv](https://docs.astral.sh/uv/)** for Python. The dependencies are pinned in `pyproject.toml`; `mlx-lm` is pinned exactly, because its Gemma 4 layer code is what runs each layer. - **llama.cpp** (or ik_llama.cpp) to use the result. See the README for launch flags. Only **Gemma 4 31B** (dense, 60 layers) is supported. The 26B-A4B MoE shares its `model_type`, and the code refuses it. ```bash git clone ecce-vector && cd ecce-vector uv sync # creates .venv with the pinned dependencies hf download google/gemma-4-31B-it --local-dir gemma-4-31B-it \ --include "*.safetensors" "*.json" "tokenizer*" "chat_template.jinja" hf download SomeAuthor/Some-Finetune-31B --local-dir Some-Finetune-31B \ --include "*.safetensors" "*.json" ``` ## 1. Fit ```bash uv run python -m ecce_vector.fit --base gemma-4-31B-it --tune Some-Finetune-31B \ --out fits --name some-finetune # a LoRA release: fit from the adapter, applied on stock uv run python -m ecce_vector.fit --base gemma-4-31B-it --adapter Some-LoRA \ --out fits --name some-lora ``` About 5.5 minutes on an M1 Max, most of it reading weights. Run with `--smoke` first to time two layers. For each of the 60 layers, the fit runs stock's layer and the fine-tune's layer on the fine-tune's own hidden states over 8 short prompts (4 roleplay, 4 novel prose; `ecce_vector/data/fit_corpus.json`) and averages the difference of their outputs. That average is the part of the fine-tune's edit that is the same at every token: all that a single vector per layer can carry. The **tracking fit** (`vectors_track`, the one released here) goes one step further. It runs *stock plus the vectors fitted so far* and fits each layer's vector to bring that steered stream back to the fine-tune's, so the drift that early layers' vectors cause is corrected as it builds up. It is fitted the way llama.cpp applies a vector: nothing at layer 0, and added at every position including the first. It writes `fits/some-finetune.npz` (the vectors, and the fine-tune's final hidden states on both prompt splits, which the score compares against) and `fits/some-finetune.json` (per-layer diagnostics). The printed **constant share** is the fraction of the edit's energy that is the same at every token. It ranks how well a vector will carry a fine-tune about as well as anything tried (Spearman +0.76 over the releases here), but it misjudges some tunes one by one, so score anyway. The fine-tunes a vector cannot carry at all are refused: an untied `lm_head` (the output head is not in the residual stream). A fine-tune that changed its embedding (so its tied output head) is fitted, and its own log-probs are saved beside the fit as `NAME.logp.npz` (about 1 GB), because stock's head reads its hidden states differently. How much that costs varies a lot: see the README's note on `melody1437-v2`. ## 2. Score, and pick the strength ```bash uv run python -m ecce_vector.score --base gemma-4-31B-it --ref fits/some-finetune.npz \ --vector fits/some-finetune.npz::vectors_track --alphas 0.5 0.75 1.0 1.25 1.5 \ --out fits/some-finetune.score.json ``` About 5 minutes for five strengths. Each strength is one extra stream through stock's layers, computed as a difference from the stock stream, so extra arms are cheap. The score runs stock with the vector added, on 16 prompts (the 8 fit prompts and 8 held-out ones), and compares its next-token distributions with the fine-tune's at 128 reference positions per prompt: - **recovery** = 1 − KL(tune ‖ stock + vector) / KL(tune ‖ stock). 1 is a perfect copy, 0 is no better than stock, and below 0 means the vector moves stock *away* from the fine-tune. - **top-1 recovery**: the same for the share of positions where the fine-tune's most likely next token differs from stock's and the vector restores it. Pick the strength on the fit split and quote the held-out split; the output's `best` does exactly that, for each vector scored. The released files use the tracking fit at that strength. Most tunes here pick 1.25. A vector's score measures it against the fine-tune's full-precision weights on these prompts: both read the same text and their next-word predictions are compared at every position (teacher-forced), and neither writes anything. It says nothing about how replies read, and it charges a miss less where the fine-tune is unsure of its next token, which flatters tunes that make stock *less* certain. Read top-1 recovery beside it. In the README's blind test, judges reading the replies found only a faint hint of each fine-tune in its vector's output, including vectors scoring above 90%. ## 3. Export for llama.cpp ```bash uv run python -m ecce_vector.export fits/some-finetune.npz::vectors_track \ vectors/some-finetune.gguf --alpha 1.25 --name some-finetune ``` The GGUF holds `alpha × c_L` for layers 1–59 as F32 (llama.cpp rejects other types and never applies a layer-0 vector), so it loads at strength 1.0 and weighs 1.27 MB. ## 4. A merge's vector from its parents' vectors (advanced) A mergekit merge in the linear family (`linear`, `task_arithmetic`, `della_linear`, `dare_linear`, …) on stock moves the model by a weighted sum of its parents' edits, so the merge's vector is the same weighted sum of the parents' vectors, layer by layer. `bake` reads the recipe exactly as mergekit resolves it (gradients across depth, per-module filters, normalisation, lambda) and writes one file: ```bash uv run python -m ecce_vector.bake recipe.yaml vectors/my-merge.gguf \ --parent ./ReadyArt/gemma-4-31B-it-scotoma-2=vectors/scotoma-2.gguf \ --parent ./Blazed-Forge/Gemma-4-Giftige-Blume-31B-v1=vectors/giftige-blume-v1.gguf \ --parent ./zerofata/G4-MeroMero-v2-31B=vectors/meromero-v2.gguf ``` Each `--parent` maps a `model:` entry of the recipe, spelled exactly as the recipe spells it, to that parent's vector. `examples/schattenblume-recipe.yaml` is the real recipe of `Nimbz/Schattenblume-31B`. The file baked from it recovers 0.894 of the merge, against 0.899 for a vector fitted to the merge's own weights: no model weights needed. Why bake rather than blend at launch? llama.cpp's `--control-vector-scaled` takes one weight per file, and a recipe usually weights each parent differently at different depths. For `schattenblume`, one constant weight per parent (the recipe's weights averaged over layers) recovers only 0.844. Blending at launch is still the easy way to *invent* a mix, and you can score any blend: ```bash uv run python -m ecce_vector.score --base gemma-4-31B-it --ref fits/my-merge.npz \ --gguf vectors/my-merge.gguf --blend vectors/a.gguf:0.5,vectors/b.gguf:0.5 ``` Refused: methods that are not a weighted sum (`slerp`, `model_stock`, `arcee_fusion`, …), a base other than stock, and a recipe that weights a layer's attention and MLP differently (one vector per layer cannot express that). `ties`/`dare_ties`/`della` are baked without their sign-election step, with a warning; that case is untested. ## 5. Generate comparisons ```bash uv run python -m ecce_vector.examples --server llama-server --model gemma-4-31B-it-Q4_K_M.gguf \ --prompts examples/prompts.json --out runs/stock uv run python -m ecce_vector.examples --server llama-server --model gemma-4-31B-it-Q4_K_M.gguf \ --prompts examples/prompts.json --out runs/some-finetune \ --control-vector-scaled vectors/some-finetune.gguf:1.0 ``` One llama-server per run (control vectors are fixed at launch), Google's recommended sampling for Gemma 4 (temperature 1.0, top-k 64, top-p 0.95, nothing else), a fixed seed, and llama-server's own prompt and generation timings saved beside each reply in `run.json`. ## Reproducing this release The prompts are fixed (`ecce_vector/data/fit_corpus.json` holds the exact token ids), and the fit and score are deterministic on a given MLX build. The code in this repository was checked against the research code that produced the released numbers: | check | result | |---|---| | the corpus module rebuilds the original token arrays, masks and scored rows | identical | | `bake` on the `schattenblume` recipe against the original baker | identical (0.0 difference) | | `score` of `schattenblume`'s vector / its recipe bake, held out | 0.89876 / 0.89388, against 0.89876 / 0.89386 | | `fit` of `storymaxxed2` from its released weights against the stored fit | bit-identical, every layer | Details in `metrics/package-checks.md`.