Runbook

Teach a local model to write code the way you do.

This guide turns your day-to-day coding activity into a fine-tuned model that runs entirely on your Mac. No cloud, no API keys, no code leaving your machine. Four stages: capture what you write, clean it into training pairs, adapt a 7B coder model with LoRA on Apple's MLX framework, and plug the result into your IDE.

The whole loop in five commands

Once your dataset exists (stages 1–2 build it), the machine-learning half of this project is genuinely this short:

terminal — end-to-end
# 1. Install training extras
pip install "mlx-lm[train]"

# 2. Train a LoRA adapter (QLoRA, since the base model is 4-bit)
mlx_lm.lora --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --train --data ./data --mask-prompt --adapter-path ./devstyle-adapters

# 3. Sanity-check the adapter before fusing
mlx_lm.generate --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --adapter-path ./devstyle-adapters --prompt "Add retry logic to this HTTP client"

# 4. Fuse adapter + base into one deployable model
mlx_lm.fuse --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --adapter-path ./devstyle-adapters --save-path ./devstyle-model

# 5. Serve it on an OpenAI-compatible endpoint for your IDE
mlx_lm.server --model ./devstyle-model --port 8080
What changed vs. the old guide

Commands here follow the current mlx-lm package: install with the [train] extra, use --num-layers (not the retired --lora-layers), add --mask-prompt for chat data, and note that valid.jsonl is optional. The deploy section also fixes a real trap: mlx_lm.fuse --export-gguf does not support Qwen models, so the Ollama route needs llama.cpp's converter — details in Deploy.

Concepts

How it works

The gap you're bridging is between raw IDE activity (keystrokes, diffs, chat logs) and supervised fine-tuning data (clean prompt → response pairs). The system has three layers:

1

Observation

Intercept meaningful units of work — commits, file saves, AI-chat edits — without drowning in noise. A keystroke logger captures typos and backspaces; a commit hook captures code you decided was done. Signal quality is decided here, before any ML happens.

2

Refinement

Strip secrets and PII, drop reverted or generated code, trim each example to the function or class that actually changed, and emit strict one-object-per-line JSONL in a chat format the trainer understands.

3

Adaptation

Run QLoRA — LoRA on top of a 4-bit quantized base model — natively via mlx-lm on Apple Silicon. You train small "adapter" matrices (well under 1% of total parameters) while the base weights stay frozen. That's why a 7B model fits comfortably in 36 GB of unified memory.

What LoRA and QLoRA actually do (60-second version)

Full fine-tuning updates every weight matrix W in the model — billions of parameters, needing optimizer state for each. LoRA (Low-Rank Adaptation) freezes W and instead learns a correction ΔW = A × B, where A and B are tiny low-rank matrices. Only A and B get gradients and optimizer state, collapsing memory needs.

QLoRA is the same trick with the frozen base stored in 4-bit precision, quartering the memory the base model occupies. In mlx-lm you don't flip a switch for this — if the model you pass to --model is already quantized, training is automatically QLoRA. The mlx-community/…-4bit repos are pre-quantized for exactly this.

Adapters are also composable: the output of training is a small adapters.safetensors file (tens of MB) you can version, swap, or delete without touching the multi-GB base model.

Why MLX and unified memory matter

On a discrete-GPU machine, model weights shuttle between CPU RAM and VRAM, and VRAM is the hard ceiling. Apple Silicon has one pool of unified memory shared by CPU and GPU, and MLX is Apple's array framework built around that: no copies, lazy evaluation, Metal-accelerated kernels. Practically, your 36 GB machine behaves like a GPU with ~30 GB of usable VRAM — enough for a 4-bit 7B base (~4.5 GB), adapter gradients, KV caches, and your browser.

Planning

Hardware & memory budget

Everything below is tuned for a 36 GB Apple Silicon MacBook Pro. The base model is small; what eats memory during training is activations, which scale with batch size × sequence length × number of trained layers.

ConfigurationApprox. training footprintVerdict on 36 GB
7B 4-bit · batch 1 · 16 layers · 2k tokens~9–12 GBComfortable — recommended start
7B 4-bit · batch 2 · 16 layers · 4k tokens~14–20 GBFine; close apps if sequences are long
7B 4-bit · batch 4 · all layers · 8k tokens28 GB+Risky — expect swapping or OOM
14B 4-bit · batch 1 · 8 layers · 2k tokens~16–22 GBPossible, slow; only if 7B disappoints
Rules of thumb
  • Memory too tight? In order: drop --batch-size to 1 → use --grad-accumulation-steps 4 to keep the effective batch → reduce --num-layers (16 → 8 → 4) → shorten your training examples → add --grad-checkpoint.
  • Gradient accumulation buys batch-size quality at batch-size-1 memory cost. Use it before sacrificing layers.
  • Fewer trained layers = less memory and less style transfer. 8–16 layers is the sweet spot for style tasks on 7B.

Expected wall-clock on an M3/M4 Pro-class chip: a few hundred tokens per second of training throughput, so 600–1000 iterations over a ~500-example dataset finishes in one to a few hours — start it before lunch, evaluate after.

Stage 01 · Observe

Capture tooling

The hardest engineering problem in this pipeline is getting logical chunks of effort instead of noise. Every keystroke is mostly typos and backspaces; a commit is a unit of work you signed off on. Three architectures, in ascending order of setup cost:

A post-commit hook snapshots every commit as a training pair: the commit message (plus any linked ticket) becomes the prompt, the diff becomes the response. Zero infrastructure, and every captured example is code you considered working.

Pros
  • Cleanest possible signal — only shipped code
  • ~30 lines of shell to deploy
  • Works across every editor and language
Cons
  • Misses iterative problem-solving steps
  • Captures final style, not reasoning
  • Quality depends on commit-message hygiene

Drop this in .git/hooks/post-commit (and chmod +x it), or set it once for all repos via git config --global core.hooksPath:

.git/hooks/post-commit
#!/usr/bin/env bash
# Snapshot each commit as a raw training event (JSON, one file per commit).
CAPTURE_DIR="$HOME/.devstyle/raw"
mkdir -p "$CAPTURE_DIR"

HASH=$(git rev-parse HEAD)
MSG=$(git log -1 --pretty=%B)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Diff only, excluding lockfiles and generated noise
DIFF=$(git show --pretty=format: --patch HEAD -- \
  ':!*.lock' ':!package-lock.json' ':!*.min.js' ':!dist/*' ':!*.snap')

# Skip merges, reverts, and empty diffs
case "$MSG" in Merge*|Revert*) exit 0;; esac
[ -z "$DIFF" ] && exit 0

jq -n --arg hash "$HASH" --arg msg "$MSG" \
      --arg branch "$BRANCH" --arg diff "$DIFF" \
      '{status:"success", commit:$hash, branch:$branch,
        commit_message:$msg, diff_output:$diff,
        ts: now | todate}' \
  > "$CAPTURE_DIR/$HASH.json"
Upgrade the signal

Pair the hook with a file-save watcher (e.g. fswatch or an editor on-save task) that stamps intermediate snapshots. Commits give you the "what", save-points give you a taste of the "how" — the highest signal-to-noise combination for a local trainer.

Run an open-source assistant like Continue.dev locally and harvest its session logs. You capture the question you asked, what the AI proposed, and — crucially — how you edited its answer before accepting it. That human-correction delta is premium style data.

Pros
  • Data already arrives as structured chat turns
  • IDE integration exists — nothing to build
  • Corrections encode your preferences explicitly
Cons
  • Biased toward how you talk to AIs, not how you write from scratch
  • Log formats change between tool versions

Point your cleaner at the tool's local session store (for Continue.dev that lives under ~/.continue/) and map your accepted/edited final code — never the AI's raw output — to the assistant role. Training on the AI's own unedited answers teaches the model to imitate the AI, not you.

OpenTelemetry is the vendor-neutral observability standard (traces, metrics, logs). An IDE plugin exports events to a local OTel Collector, whose processor pipeline filters and batches before writing to disk. Modern AI coding tools already emit OTel metrics, so the plumbing is standardized.

Pros
  • Standard OTLP format, rich processor chains
  • Captures context transitions — which files you read before you typed
  • Scales to a whole team later
Cons
  • Real infrastructure to stand up and maintain
  • Firehose volume — you'll spend most of your time filtering
  • Overkill for one developer, one laptop

Choose this only if you already run a Collector or plan to grow this beyond your own machine.

How much data do you need?

ExamplesWhat to expect
< 100Enough to shift tone and formatting habits; expect overfitting fast. Keep iterations low (100–200).
300–1,000The sweet spot for personal style transfer — naming conventions, error-handling patterns, comment voice.
3,000+Starts capturing architectural habits. Diminishing returns for a single developer; curation quality matters more than count.

A working developer produces roughly 5–15 usable commits a day, so two to eight weeks of passive capture builds the sweet-spot dataset without any behavior change.

Stage 02 · Refine

Data cleaning pipeline

Raw diffs go in; strict, secret-free, one-line-per-example JSONL comes out. The order of operations matters — redact before anything else touches disk in a shareable form.

1

Ingest

Aggregate the raw capture files (~/.devstyle/raw/*.json) into one list of events.

2

Redact — non-negotiable

Run gitleaks over the corpus first (gitleaks detect --no-git --source ~/.devstyle/raw -f json) — it ships hundreds of tuned detectors for cloud keys, tokens, and connection strings. Then apply a regex sweep as a second net for internal IPs, emails, and anything gitleaks' rules don't know about your company. Replace matches with stable placeholders like [REDACTED_SECRET] so the model learns "a secret goes here" rather than a literal value.

3

Filter noise

Drop merges, reverts, lockfile-only and vendored changes, giant auto-generated diffs, and exact duplicates. One bad 40k-line snapshot diff can dominate a 500-example dataset.

4

Window the context

Trim each example to the function or class surrounding the edit. Long examples aren't just slower — they're the single biggest memory multiplier during training. Cap around 2,000 tokens (~8,000 characters) per example unless you have a reason not to.

5

Format & split

Emit chat-format JSONL and split ~90/10 into train.jsonl and valid.jsonl. The validation file is optional for mlx_lm.lora, but without it you're flying blind on overfitting — always make one.

pipeline_processor.py
import json, re, glob, random, pathlib

RAW_DIR   = pathlib.Path.home() / ".devstyle/raw"
OUT_DIR   = pathlib.Path("./data")
MAX_CHARS = 8_000          # ≈2k tokens; keeps training memory sane
SYSTEM    = ("You are a senior engineer who writes code in the exact "
             "style, structure, and conventions of this user's codebase.")

SECRET_RE = re.compile(
    r'(?i)(api[_-]?key|secret|token|password|authorization|aws_[a-z_]+)'
    r'\s*[:=]\s*["\047]?[A-Za-z0-9+/_\-\.]{8,}["\047]?')
IP_RE     = re.compile(r'\b(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)(?:\.\d{1,3}){2,3}\b')
EMAIL_RE  = re.compile(r'[\w\.-]+@[\w\.-]+\.\w+')

def scrub(text):
    text = SECRET_RE.sub(r'\1=[REDACTED_SECRET]', text)
    text = IP_RE.sub('[REDACTED_IP]', text)
    return EMAIL_RE.sub('[REDACTED_EMAIL]', text)

def usable(ev):
    diff = ev.get("diff_output", "")
    if ev.get("status") != "success" or not diff.strip():
        return False
    if len(diff) > MAX_CHARS * 3:        # generated / vendored monster
        return False
    return True

def to_chat(ev):
    intent = scrub(ev.get("commit_message", "Update functionality").strip())
    code   = scrub(ev["diff_output"])[:MAX_CHARS]
    return {"messages": [
        {"role": "system",    "content": SYSTEM},
        {"role": "user",      "content": f"Implement the following change:\n{intent}"},
        {"role": "assistant", "content": code},
    ]}

events = [json.load(open(p)) for p in glob.glob(str(RAW_DIR / "*.json"))]
rows   = [to_chat(e) for e in events if usable(e)]

# De-duplicate on assistant content, then shuffle and split 90/10
seen, unique = set(), []
for r in rows:
    key = r["messages"][2]["content"][:400]
    if key not in seen:
        seen.add(key); unique.append(r)

random.seed(13); random.shuffle(unique)
cut = max(1, int(len(unique) * 0.1))
OUT_DIR.mkdir(exist_ok=True)
for name, chunk in [("valid", unique[:cut]), ("train", unique[cut:])]:
    with open(OUT_DIR / f"{name}.jsonl", "w") as f:
        for row in chunk:
            f.write(json.dumps(row) + "\n")

print(f"{len(unique)} examples → train/valid written to {OUT_DIR}/")

Dataset formats mlx_lm.lora accepts

The loader auto-detects the format from the keys on each line. Every example must live on exactly one line.

FormatShapeUse for
chat{"messages":[{role,content},…]}This pipeline. Model's chat template is applied automatically.
completions{"prompt":"…","completion":"…"}Simple pair data without a system prompt.
text{"text":"…"}Raw continued pre-training; full manual control of formatting.
tools{"messages":[…],"tools":[…]}Teaching function-calling behavior.
Train on the answer, not the question

By default the loss is computed over every token, including your prompt. Pass --mask-prompt at training time so only the assistant turn (your code) drives learning. For a style-cloning task this is the difference between "learns my code" and "learns my commit messages."

Stage 03 · Adapt

Fine-tuning with MLX

1 · Environment

terminal — setup
# Clean virtual environment
python3 -m venv .mlx_env
source .mlx_env/bin/activate

# mlx-lm with training dependencies, plus the HF hub for model downloads
pip install -U "mlx-lm[train]" huggingface_hub

# Optional but useful
pip install gitleaks-py wandb   # experiment logging: add --report-to wandb

2 · Configuration

You can pass everything as CLI flags, but a YAML config is easier to version alongside your dataset. Flags override the config, so keep the stable stuff in the file and experiment on the command line.

devstyle.yaml
# Base: 4-bit quantized ⇒ training runs as QLoRA automatically
model: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit"
train: true
data: "./data"              # contains train.jsonl (+ valid.jsonl)
adapter_path: "./devstyle-adapters"

fine_tune_type: lora        # lora | dora | full
num_layers: 16              # layers to adapt; lower ⇒ less memory
batch_size: 1
grad_accumulation_steps: 4  # effective batch of 4 at batch-1 memory
iters: 800
learning_rate: 1e-5
mask_prompt: true           # loss on assistant tokens only
steps_per_eval: 100         # validation-loss cadence
save_every: 200             # checkpoint cadence

3 · Run it

terminal — training
mlx_lm.lora --config devstyle.yaml

# Interrupted? Resume from the last checkpoint:
mlx_lm.lora --config devstyle.yaml \
  --resume-adapter-file ./devstyle-adapters/adapters.safetensors

Reading the training log

  • Train loss should fall steadily. On a small personal dataset, landing somewhere around 1.0–1.8 is typical for code; the exact number matters less than the trend.
  • Val loss (printed every steps_per_eval) is your overfitting alarm. Falling with train loss: keep going. Flat while train loss falls: you're at the useful limit. Rising: stop and use the earlier checkpoint.
  • Tokens/sec collapsing mid-run means you've spilled out of unified memory into swap — kill it and apply the memory ladder below.
Out-of-memory ladder

Apply in order, re-testing after each: --batch-size 1--grad-accumulation-steps 4--num-layers 8 (then 4) → shorten examples in the JSONL → --grad-checkpoint (trades compute for memory; most helpful with quantized models and longer sequences).

Stage 03.5 · Verify

Evaluate before you ship

Fusing and deploying a bad adapter wastes an afternoon. Two cheap checks first:

Perplexity on held-out data

Copy valid.jsonl to test.jsonl (or reserve a real test split) and run:

terminal — test loss
mlx_lm.lora --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --adapter-path ./devstyle-adapters --data ./data --test

The vibe check

Numbers can't measure "sounds like me." Generate against prompts shaped like your real commits and read the output critically:

terminal — generation A/B
# With your adapter
mlx_lm.generate --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --adapter-path ./devstyle-adapters \
  --prompt "Implement the following change:\nAdd pagination to the /users endpoint"

# Same prompt, no adapter — the control
mlx_lm.generate --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --prompt "Implement the following change:\nAdd pagination to the /users endpoint"

Score the A/B pair against a personal rubric:

Failing the last two? Retrain with fewer iterations or more data. Passing? Ship it.

Stage 04 · Ship

Fuse and deploy to your IDE

1 · Fuse the adapter into a standalone model

The adapter is only weight deltas; fusing bakes them into a copy of the base so any runtime can load it without knowing about LoRA:

terminal — fusing
mlx_lm.fuse --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --adapter-path ./devstyle-adapters \
  --save-path ./devstyle-model

2 · Pick a serving route

mlx-lm ships an OpenAI-compatible HTTP server — no conversion, native MLX speed. It can even load the un-fused adapter directly:

terminal — serve
# Fused model…
mlx_lm.server --model ./devstyle-model --port 8080

# …or skip fusing entirely and serve base + adapter:
mlx_lm.server --model mlx-community/Qwen2.5-Coder-7B-Instruct-4bit \
  --adapter-path ./devstyle-adapters --port 8080

LM Studio runs MLX models natively on Apple Silicon. Move (or symlink) ./devstyle-model into ~/.lmstudio/models/<you>/devstyle-model/, reload the model list, load it, and enable the local server (default port 1234, OpenAI-compatible). Zero conversion, plus a GUI for sampling settings.

The Qwen → GGUF trap

mlx_lm.fuse --export-gguf only supports Mistral-, Mixtral-, and Llama-family models — it will not export a Qwen model. Convert with llama.cpp's script instead, and convert from an fp16 fuse of the full-precision base, not the 4-bit one, then let quantization happen on the GGUF side.

terminal — GGUF via llama.cpp
# Fuse against the full-precision base for a clean fp16 export
mlx_lm.fuse --model Qwen/Qwen2.5-Coder-7B-Instruct \
  --adapter-path ./devstyle-adapters --save-path ./devstyle-fp16

git clone https://github.com/ggml-org/llama.cpp
python llama.cpp/convert_hf_to_gguf.py ./devstyle-fp16 \
  --outfile devstyle.gguf --outtype q8_0

printf 'FROM ./devstyle.gguf\n' > Modelfile
ollama create devstyle -f Modelfile
ollama run devstyle

3 · Point Continue.dev at it

~/.continue/config.yaml
name: DevStyle Local
version: 1.0.0
schema: v1
models:
  # Route A/B — mlx_lm.server or LM Studio (OpenAI-compatible)
  - name: DevStyle 7B
    provider: openai
    model: devstyle-model
    apiBase: http://localhost:8080/v1
    roles: [chat, edit, apply]

  # Route C — Ollama
  - name: DevStyle 7B (Ollama)
    provider: ollama
    model: devstyle
    roles: [chat, edit]

  # Keep a small fast model for tab-autocomplete
  - name: Autocomplete
    provider: ollama
    model: qwen2.5-coder:1.5b
    roles: [autocomplete]

Open a file, hit Cmd+L, pick DevStyle 7B, and watch it draft code in your own conventions.

Field notes

Pitfalls & FAQ

My model just parrots my commit messages back at me

Classic overfit on a small dataset, usually combined with unmasked prompts. Add --mask-prompt, cut iters by half, and check val loss — the moment it turns upward is where your checkpoint should come from.

Training runs but my Mac becomes unusable

You're on the edge of the unified-memory budget and macOS is compressing/swapping. Follow the OOM ladder in the Train section, close memory-hungry apps (browsers, Docker), and prefer gradient accumulation over batch size.

Can I fine-tune starting from a GGUF file I already have?

No. mlx-lm trains from Hugging Face-format (safetensors) models only; GGUF is an inference format. Pull the mlx-community 4-bit repo of the same model instead.

Do I need valid.jsonl?

The trainer runs without it, but you lose the validation-loss signal that tells you when to stop. Ten percent of your data is a cheap insurance policy — always split one off.

Should I use a bigger model than 7B?

On 36 GB, a 4-bit 14B is trainable at batch 1 with few layers, but slower and tighter. Style transfer is not capability-hungry — most of what you're teaching is surface convention, which a 7B absorbs well. Exhaust data quality improvements before spending memory on parameters.

Is my code safe in this pipeline?

Everything runs locally: capture writes to your home directory, training and inference never leave the machine. The two footguns are (1) skipping redaction and later sharing the model or dataset, and (2) uploading a fused model to a public Hub repo with secrets baked into its training data. Redact first, share deliberately.

How do I keep the model current as my style evolves?

Keep the capture hook running. Every month or two, re-run the cleaner on the accumulated raw events and retrain from the base model with the full refreshed dataset (don't stack adapter-on-adapter — retraining from base is simpler and avoids drift compounding). The adapter file is small; date-stamp and keep old ones for rollback.