Skip to content
ash-hunPublic

Repository files navigation

LLMClick

Documentation: https://ash-hun.github.io/LLMClick/ (generated from this README, the channel READMEs and the code; see website/README.md).

Config-driven custom model building. One YAML describes one custom model; pipeline.recipe picks the recipe that builds it and pipeline.stages picks the steps to run. Every recipe runs on the same pipeline runner, which skips what is already built, shows progress, and never hands on a trained model that has not passed validation.

Family Recipe (pipeline.recipe) Trains Model catalog (model.architecture)
LLM llm_sft conversations, loss on every assistant turn transformer (Qwen3-0.6B, 1.7B), hybrid (Qwen3.5-0.8B, 2B)
LLM llm_instruction instruction/input/output records same
LLM llm_dpo chosen/rejected preference pairs same
LLM llm_orpo the same pairs: SFT on chosen plus an odds-ratio term, no reference model same
LLM llm_simpo the same pairs: a margin on the mean token log-probability, no reference model same
LLM llm_kto answers labelled desirable or not, no pairs needed same
LLM llm_reward the same pairs: a reward model, one score per answer (Bradley-Terry), saved as a sequence classifier same
LLM llm_agent_rl prompts answered over several turns with an environment's tools (calculator, document lookup); trajectories rewarded by the final answer (agentic GRPO) same
LLM llm_grpo prompts scored by a reward function (answer match, regex, length, a teacher judge, an llm_reward model, or a weighted sum) same
LLM llm_distill prompts the student answers itself, every token graded by a larger teacher with the same vocabulary (on-policy distillation) same
LLM llm_pretrain plain text, documents packed into full-length blocks, loss on every token (continued pretraining) same
LLM llm_tokenizer nothing: the words of a corpus worth adding to a tokenizer, and the tokens they save; training recipes apply them with model.tokenizer (also special tokens and a chat template) same
LLM llm_quantize nothing: a trained checkpoint or Hub model quantized (AWQ, GPTQ, W8A8, FP8, bitsandbytes NF4/INT8) or exported to GGUF, its held-out loss against the base same
LLM llm_merge nothing: fine-tunes of one base merged into one model (linear, slerp, task arithmetic, TIES, DARE), its held-out loss against the base same
LLM llm_decision_sft decision questions: a head scores the options (Jev, jeff, Jeeves stage 1) same, plus model.head: readout or pointer
LLM llm_decision_cispo the same questions with reasoning before the decision, by reinforcement (Jeeves stage 2) same
Embedding embedding_contrastive query/positive/negative triples (InfoNCE) bi_encoder (Qwen3-Embedding-0.6B)
TTS tts_piper recordings and their texts: a Piper (VITS) voice fine-tuned from a Piper checkpoint, exported to ONNX model.quality: medium, high (Piper's sizes)
TTS tts_qwen the same recordings: a Qwen3-TTS voice cloned from the most natural one, nothing trained (Apple silicon) model: a Qwen3-TTS Base model on MLX
Wake word wakeword recordings of the agent's name ("Jarvis") and of everything else: an openWakeWord detector, exported to ONNX openWakeWord's dnn head on its frozen speech features
Evaluation evaluation_custom nothing: scores a checkpoint of any experiment above on any rows, with that recipe's metrics the experiment's
Evaluation evaluation_benchmark nothing: scores a checkpoint on public benchmarks (lm-evaluation-harness), one cached stage per benchmark the experiment's (LLM)
Evaluation evaluation_compare nothing: several checkpoints (the base model among them) on the same benchmarks and rows; table, deltas with intervals, polygon chart the experiments'
Evaluation evaluation_decision nothing: a decision checkpoint on Jev rows or the JevBench tiers, without and with reasoning; accuracy, NLL, ECE, per tier, latency the experiment's (decision)
Evaluation evaluation_embedding nothing: an embedding checkpoint on MTEB tasks (mteb), one cached stage per task the experiment's (embedding)
Evaluation evaluation_wakeword nothing: a wake word detector on recordings and hours of audio it never heard; miss rate per noise condition, false accepts per hour, the threshold sweep the experiment's (wake word)
Data data_synthetic nothing: rows for the recipes above from seeds through a teacher model (seeds, prompts, evolve, respond, verify, select) teachers: anthropic, openai, ollama, local
Data data_speech nothing: a Piper training set from seeds: texts (a teacher, or the seeds as they are) read by a Piper voice, checked by Whisper voice: any Piper .onnx, one speaker or many

The decision recipes follow the Jev request format. The readout head and one-pass training follow jeff; the pointer head, reasoning chains and CISPO follow Jeeves. Both are reimplemented on this framework's training loop, so their released checkpoints do not load here.

01. Quick start

From a fresh machine to a trained, validated checkpoint you can load. The shipped configs are pilots: they train four steps on the sample rows in samples/, so a first run takes minutes and shows every stage before you point a config at your own data.

What you need

  • git and uv (curl -LsSf https://astral.sh/uv/install.sh | sh). uv installs Python 3.12 itself; nothing else is installed by hand.
  • One of: an Apple silicon Mac (training runs on MPS), or a Linux server with an NVIDIA GPU (see NVIDIA GPU server). A CPU-only machine trains the architecture: transformer configs only.
  • Disk for the model cache: Qwen3.5-0.8B and Qwen3-0.6B take about 1.5 GB each under ~/.cache/huggingface.

1. Install

git clone https://github.com/ash-hun/LLMClick.git
cd LLMClick
uv sync --frozen                                           # the locked environment in .venv
cp environment/.env.sample environment/.env                # optional: HF_TOKEN, API_TOKEN, teacher keys

The shipped models are public, so the .env file can stay empty. Fill HF_TOKEN for gated or private models, API_TOKEN before you run the API on a server others can reach.

2. Check a config

uv run llmclick recipes                                    # recipes, their stages and the names a config may use
uv run llmclick validate configs/llm/sft.yaml              # experiment key and stage plan; builds nothing
{
  "valid": true,
  "recipe": "llm_sft",
  "experiment": "sft-qwen3.5-0.8b-14fc97a0",
  "directory": "output/sft-qwen3.5-0.8b-14fc97a0",
  "stages": ["data", "train", "validate"]
}

3. Run it

uv run llmclick run configs/llm/sft.yaml                   # Mac or NVIDIA GPU
uv run llmclick run configs/llm/sft_transformer.yaml       # CPU only: Qwen3-0.6B

The first run downloads the model, then shows a progress bar per stage. Logs go to stderr, the result to stdout as JSON: the rows each stage made, the training loss, the held-out metrics and whether validation passed (validation.max in the config is the bar). A run that misses it stops with ValidationFailed and hands on no checkpoint.

Run the same command again and every stage reports cached within seconds. Change training.lr and only train and validate run again; the data stage is shared.

4. Find and use the model

Each config gets one experiment directory:

output/sft-qwen3.5-0.8b-14fc97a0/
├── config.yaml          # the config as it ran
├── manifest.json        # every stage's fingerprint and outputs
├── pipeline.log
├── data -> ../_stages/data-<fingerprint>/        # train.jsonl, validation.jsonl
├── train -> ../_stages/train-<fingerprint>/
└── validate -> ../_stages/validate-<fingerprint>/
    ├── validation.json  # the metrics and the verdict
    └── checkpoint/      # the validated model: a Hugging Face directory (LoRA adapters merged in)

The checkpoint loads with transformers like any Hub model:

from transformers import AutoModelForCausalLM, AutoTokenizer

path = "output/sft-qwen3.5-0.8b-14fc97a0/validate/checkpoint"
tokenizer = AutoTokenizer.from_pretrained(path)
model = AutoModelForCausalLM.from_pretrained(path)

messages = [{"role": "user", "content": "What is 3 plus 4?"}]
inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt", return_dict=True)
output = model.generate(**inputs, max_new_tokens=20)
print(tokenizer.decode(output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))   # 3 plus 4 is 7.

5. Train on your own data

  1. Write your rows as JSON Lines in the recipe's shape (samples/README.md lists every shape). For llm_sft, one conversation per line:

    {"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}
  2. Copy the closest config and edit it:

    cp configs/llm/sft.yaml configs/llm/my_model.yaml
    • pipeline.name: your model's name, the start of the experiment directory's name;
    • data.sources: path: to your file (or a huggingface source, as in configs/llm/grpo_gsm8k.yaml);
    • training: remove max_steps for a full run, set epochs, lr, batch_size;
    • validation.max: the held-out loss your model must reach to be handed on.
  3. uv run llmclick validate configs/llm/my_model.yaml, then uv run llmclick run configs/llm/my_model.yaml.

If a run stops halfway (out of memory, a lost connection), run the same command again: finished stages are reused and training resumes from its last saved step.

6. Run it as a server

uv run llmclick serve --host 127.0.0.1 --port 8000         # API, with Swagger at http://localhost:8000/docs
curl -s -X POST localhost:8000/api/jobs -H 'content-type: application/json' \
  -d '{"config_path": "configs/llm/sft.yaml"}'             # returns a job_id; the job runs in the background
curl -s localhost:8000/api/jobs/<job_id>                   # status, per-stage progress, then the result

Or in Docker: IMAGE_TAG=$(git rev-parse --short HEAD) docker compose up -d --build. The API listens on the host's loopback only; from another machine, ssh -L 8000:localhost:8000 <server>. Sending the same config again returns the same job, and docs/api_spec.md describes every endpoint.

When something goes wrong

What you see What to do
RuntimeError: 0 active drivers ([]). There should only be one. a Qwen3.5 (hybrid) config on Linux without a GPU: use an NVIDIA GPU, or a transformer config
torch.cuda.is_available() is False on a GPU server NVIDIA driver older than 580, or (Docker) compose.gpu.yaml not added or nvidia-container-toolkit missing
401 or 403 from the Hub while loading a model a gated model: accept its terms on the Hub and set HF_TOKEN in environment/.env
out of memory lower training.batch_size (raise accumulation to keep the effective batch), add training.adapter (LoRA, or QLoRA with quantize: nf4 on CUDA), or set training.gradient_checkpointing: true
ValidationFailed: Validation failed for ... the model missed validation.max: train longer or change the data; no checkpoint is handed on
403 outside API_PATHS from the API configs, rows and output_dir must sit under API_PATHS (default: the working directory)

Shipped configs

Config What it builds
llm/sft.yaml SFT of Qwen3.5-0.8B (hybrid) on conversations
llm/sft_transformer.yaml the same recipe on Qwen3-0.6B (transformer)
llm/instruction.yaml instruction tuning of Qwen3.5-0.8B
llm/dpo.yaml DPO of Qwen3.5-0.8B on preference pairs
llm/kto.yaml KTO of Qwen3.5-0.8B on labelled single answers
llm/reward.yaml a LoRA reward model on Qwen3.5-0.8B from the DPO pairs, for the reward_model GRPO reward
llm/orpo.yaml, llm/simpo.yaml ORPO and SimPO of Qwen3.5-0.8B on the DPO pairs
llm/sft_tools.yaml tool calling: SFT of Qwen3.5-0.8B on conversations with three made-up tools
llm/sft_thinking.yaml thinking SFT: Qwen3.5-0.8B on sums and products worked out in a thinking block before the answer
llm/sft_lora.yaml the SFT recipe with a LoRA adapter instead of full fine-tuning
llm/sft_qlora.yaml the SFT recipe on Qwen3-1.7B with QLoRA: 4-bit base, LoRA adapter (CUDA, --extra quantize)
llm/grpo.yaml GRPO of Qwen3.5-0.8B with the exact_match reward
llm/grpo_gspo.yaml GRPO with GSPO's sequence-level clip on groups reused for two steps
llm/grpo_weighted.yaml GRPO with a weighted reward: answer match, a format regex and a length term; loss_norm: token, reward_workers for a judge
llm/distill.yaml on-policy distillation of Qwen3-0.6B from Qwen3-1.7B on the sample prompts
llm/pretrain_ko.yaml continued pretraining of Qwen3-0.6B on Korean text with the words tokenizer_ko.yaml picks, LoRA plus the new tokens' rows
llm/tokenizer_ko.yaml the Korean words worth adding to Qwen3's tokenizer, from the Korean sample rows
llm/quantize_awq.yaml 4-bit AWQ of Qwen3-0.6B with llm-compressor and the loss it costs (CUDA, --extra quantize)
llm/quantize_gguf.yaml Qwen3-0.6B as a q8_0 GGUF file for llama.cpp and Ollama (--extra quantize, then environment/llama_cpp/setup.sh)
llm/merge_ties.yaml TIES merge of the sft_transformer.yaml and distill.yaml checkpoints of Qwen3-0.6B (run those first)
llm/agent_rl.yaml agentic RL of Qwen3-0.6B: arithmetic with a calculator over up to three turns
llm/grpo_gsm8k.yaml GRPO on GSM8K word problems from the Hub with the last_number reward
llm/decision_readout.yaml one-pass decision model: full fine-tuning, readout head (Jev, jeff)
llm/decision_pointer.yaml reasoning decision model, stage 1: LoRA, pointer head, reasoning chains as context (Jeeves SFT)
llm/decision_cispo.yaml reasoning decision model, stage 2: CISPO from the stage-1 checkpoint
embedding/contrastive.yaml contrastive learning of Qwen3-Embedding-0.6B
evaluation/custom.yaml the llm/sft.yaml experiment's validated checkpoint scored on the sample rows
evaluation/benchmark.yaml the same checkpoint on the small_general preset plus KMMLU, 100 items each (needs uv sync --extra eval)
evaluation/compare.yaml base model against the trained checkpoint on the preset and the sample rows, with a four-axis chart
evaluation/decision.yaml the llm/decision_pointer.yaml checkpoint on the JevBench tiers, without and with reasoning
evaluation/embedding.yaml the embedding/contrastive.yaml checkpoint on three MTEB tasks (needs uv sync --extra eval)
data/synthetic.yaml Korean conversation rows for llm_sft from four topic seeds through a local Ollama model, evolved, judged, deduplicated
data/speech.yaml a Piper training set: assistant lines written by a local Ollama model, read by the jgkawell/jarvis high voice, checked by Whisper (needs environment/piper/setup.sh)
tts/piper_jarvis.yaml fine-tuning from the jgkawell/jarvis high checkpoint on that set, with the agent's nine read-backs; replace <hash> with the data/speech.yaml experiment's
tts/qwen_jarvis.yaml the Jarvis voice cloned by Qwen3-TTS 1.7B from the most natural clip of that set, with the same read-backs (needs Apple silicon and environment/qwen/setup.sh)
data/wake_phrase.yaml "Jarvis." (statement, question, call) read by 300 speakers of the libritts_r Piper voice, kept if Whisper hears it exactly
data/wake_negatives.yaml near misses of the name, the commands said alone, and ordinary lines, read by 40 speakers of the same voice
data/wake_nearmiss.yaml 20 near misses of the name ("Travis", "Jars", "Jarrett", ...) by 120 more speakers, kept without a listening check
wakeword/jarvis.yaml the "Jarvis" wake word detector from those three sets; replace each <hash>

The llm/ and embedding/ configs ship as pilots: sample rows from samples/ and max_steps: 4. Point data.sources at your rows and remove max_steps for a real run.

Installed as a package (uv build, then pip install dist/llmclick-*.whl), llmclick runs from any directory: config paths, output_dir and environment/.env are relative to the working directory, and LLMCLICK_ENV names a different settings file.

NVIDIA GPU server

The locked torch 2.14.0 is built against CUDA 13.0: the server needs a Linux x86_64 or aarch64 host, an NVIDIA driver of 580 or newer (nvidia-smi shows it) and an Ampere or newer GPU. Qwen3.5's linear-attention layers run through flash-linear-attention's Triton kernels, so the hybrid configs (llm/sft.yaml and the other Qwen3.5 ones) need that GPU; on a CPU-only Linux host only the architecture: transformer configs train.

On a VM with Docker and nvidia-container-toolkit:

git clone https://github.com/ash-hun/LLMClick.git && cd LLMClick
IMAGE_TAG=$(git rev-parse --short HEAD) docker compose -f compose.yaml -f compose.gpu.yaml up -d --build
docker exec llmclick python -c "import torch; print(torch.cuda.is_available())"

On a provider that hands you a container instead (no Docker inside), run uv directly: install it (curl -LsSf https://astral.sh/uv/install.sh | sh), then follow the steps above from git clone. environment/.env is optional; copy the sample to set HF_TOKEN, API_TOKEN or the teacher keys.

Not in the image: the eval and data extras (uv sync --frozen --extra eval --extra data for the benchmark, embedding and synthetic-data configs), and tts_qwen, which runs on MLX and needs Apple silicon. The recorded audio the TTS and wake word configs read (recordings/) is not in the repository, and output/ stays on the machine that made it: copy either over when a config needs it.

Apple silicon

Training and validation run on MPS; device in the config (or auto-detection: cuda, then mps, then cpu) decides. Weights are trained in FP32: fully fine-tuning Qwen3.5-0.8B needs roughly 13 GB of unified memory at the shipped batch sizes, and training.adapter (LoRA) cuts that to little more than the weights themselves.

02. Project structure

LLMClick/
├── compose.yaml                  # `docker compose up -d`: the API in a container, tagged by commit
├── compose.gpu.yaml              #   added with -f on an NVIDIA GPU server
├── main.py                       # `uvicorn main:app` from the repository root; the app itself is core/api/api_app.py
├── configs/                      # one YAML = one custom model; a directory per family (llm/, embedding/)
├── samples/                      # tiny rows per recipe so the shipped configs run offline
├── core/                         # the framework, shared by every channel
│   ├── stage_base.py             #   Stage: one fingerprinted, rerunnable unit of work
│   ├── pipeline_runner.py        #   Pipeline: runs a recipe's stages; build()/load() turn a config into one
│   ├── progress_reporter.py      #   Progress, BarProgress (terminal), StateProgress (API jobs)
│   ├── recipe_registry.py        #   Registry, RECIPES, CHANNELS
│   ├── file_store.py             #   content hashes, atomic writes, JSON and JSONL, locks
│   ├── device_selector.py        #   cuda, then mps, then cpu, unless the config names one
│   ├── logger.py settings.py     #   structlog setup (LOG_FORMAT); process settings from environment/.env (LLMCLICK_ENV)
│   ├── config/                   #   config_schema.py (BaseConfig), experiment_store.py (directories, manifest)
│   ├── cli_app.py                #   llmclick run|validate|recipes|serve
│   └── api/                      #   api_app.py; routers: config_channel, job_channel, system_channel; job_store.py, access_guard.py
├── modeling/                     # Modeling channel: config -> trained, validated model
│   ├── modeling_config.py        #   ModelingConfig: tracker, validation bounds
│   ├── modeling_stages.py        #   TrainStage, ValidateStage (templates every recipe fills in)
│   ├── modeling_pipeline.py      #   ModelingPipeline: training always brings validation
│   ├── metric_tracker.py         #   wandb replay
│   ├── tuning/                   #   in-process fine-tuning shared by the families below
│   │   ├── tuning_backbone.py    #     Backbone: base of every model catalog entry
│   │   ├── training_method.py    #     TrainingMethod: base of every training method
│   │   ├── training_loop.py      #     the one training loop (accumulation, schedule, resume)
│   │   ├── tuning_stages.py      #     data, train, validate
│   │   ├── row_sources.py        #     row sources: local_jsonl, huggingface
│   │   └── tuning_config.py tuning_pipeline.py
│   ├── llm/                      #   family: generative language models
│   │   ├── models/               #     Model Catalog: llm_backbone.py (LLMBackbone), transformer_backbone.py, hybrid_backbone.py
│   │   │   └── heads/            #       decision heads: decision_head.py (DecisionHead), readout_head.py, pointer_head.py
│   │   ├── methods/              #     Training Method: llm_method.py (LLMMethod), sft, instruction, dpo, grpo (*_method.py)
│   │   │   └── decision/         #       decision_method.py (rows, metrics, calibration), decision_sft_method.py, cispo_method.py
│   │   └── llm_config.py llm_pipeline.py  # one config class and one registered recipe per method
│   ├── embedding/                #   family: text embedding models
│   │   ├── models/               #     Model Catalog: embedding_backbone.py (EmbeddingBackbone), bi_encoder_backbone.py
│   │   ├── methods/              #     Training Method: contrastive_method.py
│   │   └── embedding_config.py embedding_pipeline.py
│   ├── tts/                      #   family: text-to-speech (tts_piper, tts_qwen)
│   │   ├── piper_process.py      #     the Piper program (GPL) as a separate process: speak, fit, export
│   │   ├── qwen_process.py       #     Qwen3-TTS as a separate process (environment/qwen)
│   │   ├── audio_file.py asr_listener.py quality_rater.py  # WAV measurements; Whisper and word error rate; UTMOS, DNSMOS
│   │   └── tts_config.py tts_stages.py tts_pipeline.py
│   └── wakeword/                 #   family: wake word detection (wakeword)
│       ├── feature_embedder.py   #     openWakeWord's frozen front end on onnxruntime: audio -> embeddings every 80 ms
│       ├── wakeword_detector.py  #     the DNN head: trained with torch, exported and scored in ONNX
│       └── wakeword_config.py wakeword_stages.py wakeword_pipeline.py
├── data/                         # Data channel: seeds -> prompts -> evolve -> respond -> verify -> select (data_synthetic)
│   ├── teacher_client.py         #   anthropic, openai, ollama, local behind one complete()
│   ├── *_stage.py row_store.py   #   one module per stage; row_store.py: natural keys, atomic files, per-item caches
│   └── speech/                   #   data_speech: synthesize, verify, select after the shared seeds and prompts
├── evaluation/                   # Evaluation channel: config -> scores of a trained model, report.json
│   ├── source_model.py           #   SourceExperiment: an experiment's config read back, its checkpoint located
│   ├── custom_stages.py          #   rows, score, report (evaluation_custom)
│   ├── contamination_check.py    #   exact and near-duplicate overlap between training rows and evaluation items
│   ├── benchmark_evaluation.py   #   presets, one score stage per benchmark over lm-evaluation-harness (evaluation_benchmark)
│   ├── compare_evaluation.py     #   runs, verdicts against the base, polygon SVG, Markdown summary (evaluation_compare)
│   ├── decision_evaluation.py    #   jevbench source, decide without and with reasoning, ECE, tiers, latency (evaluation_decision)
│   ├── embedding_evaluation.py   #   mteb encoder wrapper, one stage per MTEB task (evaluation_embedding)
│   ├── wakeword_evaluation.py    #   clips, score traces, miss rate and false accepts per hour over thresholds (evaluation_wakeword)
│   └── evaluation_config.py evaluation_pipeline.py  # (README: design and milestones)
├── tests/                        # unit/test_{feature}_{module}.py, integ/test_{feature}.py; shared fakes beside conftest.py
├── docs/                         # features.md, api_spec.md, view_spec.md
├── environment/                  # Dockerfile, .env.sample (compose.yaml sits at the root)
│   └── piper/                    #   the Piper program's own locked environment (GPL), built by setup.sh
└── output/
    ├── _stages/<stage>-<fingerprint>/   # what each stage built; shared by every experiment with the same inputs
    └── <name>-<hash>/                   # one experiment: config.yaml, manifest.json, pipeline.log, links to its stages

evaluation/ scores what modeling/ built: evaluation_custom measures any checkpoint of an experiment (validate, train, base, or a directory) on any rows with the metrics of the recipe that trained it; evaluation_benchmark scores it on public benchmarks through lm-evaluation-harness (uv sync --extra eval), one cached stage per benchmark, with presets for the small-model panel and Korean; evaluation_compare puts several checkpoints, the base model among them, through the same benchmarks and rows and reports the differences with their intervals, a polygon chart and a Markdown summary; evaluation_decision measures a decision model on Jev rows or the JevBench tiers without and with reasoning (accuracy, NLL, ECE, per tier against chance, latency); evaluation_embedding runs an embedding model on MTEB tasks; evaluation_wakeword measures a wake word detector's miss rate under noise and false accepts per hour of audio without the name. All write report.json with the overlap between the evaluation items and the experiment's training rows (exact matches and n-gram near-duplicates) next to the scores, send the report to wandb with tracker.enabled, and are listed by GET /api/evaluations (evaluation/README.md has the details). data/ produces rows: data_synthetic makes them from seeds through a teacher model (Anthropic, OpenAI-format, Ollama or a local checkpoint), evolves and verifies them, removes duplicates and leaks, and writes a recipe's rows with a manifest; every stage is cached by its inputs and safe to rerun, so a crash or a spent budget resumes where it stopped (data/README.md). Loading open datasets (data_open) is designed there and not built. A channel is a package that registers recipes; it joins by adding its name to CHANNELS in core/recipe_registry.py and reuses Stage, Pipeline, Progress and Experiment as they are.

03. How a run works

pipeline:
  recipe: llm_sft                   # which pipeline builds this model
  name: my-model
  stages: [data, train]             # optional; default all. Stages they read are added, train always brings validate
  • Fingerprints. A stage's fingerprint is its own config inputs (plus the content of local files it reads) and the fingerprints of the stages it reads. Its directory is output/_stages/<stage>-<fingerprint>/, so two configs that differ only in training settings share the data stages, and a rerun of the same config builds nothing. Each training method, backbone and head class carries a version; bumping one after changing its code rebuilds the experiments that use that part and leaves every other recipe's cache alone.
  • Reruns. A stage is recorded only when it finishes. A crashed run is resumed by running the same command again; training continues from resume.pt in the train stage directory and ends with the same weights as an uninterrupted run: batches, learning rates and the samples of a sampling method (llm_grpo, llm_decision_cispo) are all derived from the seed and the step. With method.iterations above 1 a snapshot is written only between groups of steps that share samples, so a resume never lands inside such a group.
  • Locks. A stage directory is locked while it is built, so a CLI run and an API job asking for the same stage never build it twice; the second one says that it is waiting (progress note, log) and then reuses the result.
  • Identity. The experiment directory is <name>-<hash>; stages, output_dir and tracker are not part of the hash, because they say how to run, not what to build.
  • Progress. The CLI shows two bars (stages, and steps inside the running stage); API jobs report the same state in progress. Every long phase names itself there: loading the model, checking lengths, a method's preparation (reference margins, reasoning chains), the steps, fitting the temperature, saving.
  • Lengths. Before the first training step every training and held-out row is measured against training.max_length, including the tokens a method adds later (generated completions, reasoning chains). A row that does not fit stops the run there (training.overflow: error, the default) or is left out (overflow: skip); nothing is cut silently and nothing fails hours into training. Embedding texts are the exception: they are truncated, as usual for retrieval.
  • Evaluation while training. training.eval_every: N measures the model on the held-out rows every N steps with the validate stage's own metrics (training.eval_rows caps how many rows), and records them as eval/<metric> next to the loss in training.jsonl and in the tracker. The weights are as they are at that step: for the decision recipes the temperature is not fitted yet, so nll and ece there are uncalibrated.
  • Validation. In the Modeling channel validate follows train and cannot be left out. It measures the trained model on data training never saw and checks validation.min / validation.max; a miss, or a metric that is not a finite number, stops the pipeline. A later stage reads the checkpoint from validate, never from train. Without bounds the metrics are only recorded, and a warning says so.
  • Tracking. With tracker.enabled each training step is sent to wandb as it happens; a run interrupted and resumed continues the same wandb run. tracker is not part of an experiment's identity, so switching it on later replays the already cached run once instead of retraining. A run is sent to a given project only once.
  • Jobs. The API keeps its jobs in a SQLite table (JOBS_DB), so they survive a restart; a job the server was running when it stopped is marked interrupted and resumes when submitted again. DELETE /api/jobs/<id> cancels a pending or running job: it stops at its next progress report, keeps what it built, and continues from there when submitted again. JOB_WORKERS jobs run at once, each worker on its own accelerator: set it to the number of GPUs on a multi-GPU server and keep 1 otherwise. On a server others can reach, set API_TOKEN (every /api call then needs Authorization: Bearer <token>) and API_PATHS (the directories the API may read configs and rows from and write output to; any other path is refused).
  • Typos. Every config section rejects keys it does not know, so validaton: or training.learning_rate is an error instead of a silently ignored setting.

Stages

Stage What it does
data read every data.sources[*], check each row against the recipe's method, hold out data.validation of them
train load the backbone, run the training loop with the method's loss; resumes from resume.pt after a crash
validate the method's metrics on the held-out rows against validation.min/max
rows, score, report evaluation_custom: read and check the rows, measure the chosen checkpoint with the experiment's method, write report.json
score:<task>..., report evaluation_benchmark: one lm-evaluation-harness run per benchmark (fingerprint: checkpoint content, task, few-shot, limit, decoding, harness version), then one report
score:<run>:<task>..., rows, rows:<run>..., report evaluation_compare: the stages above per run, shared with the single-model recipes; the report compares every run with the base
rows, score, report evaluation_decision: every question through the head without reasoning and after greedy reasoning; items.jsonl per question
score:<task>..., report evaluation_embedding: one mteb run per task (fingerprint: checkpoint content, task, max_length, mteb version)
clips, traces, report evaluation_wakeword: recordings and ambient WAVs found; the detector's score every 80 ms on all of them under each condition; the threshold sweep and the operating point
data, train, validate tts_piper: recordings found and split; piper.train fit from the starting checkpoint, exported to voice.onnx; the voice reads the held-out texts and the fixed replies, Whisper transcribes them
data, train, validate tts_qwen: the same split; the training clip UTMOS rates most natural becomes the voice (reference.wav, voice.json); the same listening test
data, features, train, validate wakeword: recordings of both classes found and split; noisy windows as embeddings; the detector trained and exported; held-out streams scored every 80 ms
Recipe Row keys Metrics for validation.min/max
llm_sft messages, tools? (assistant turns may carry tool_calls and reasoning_content, tool turns hold results) loss, perplexity, tool_call_exact (held-out tool calls written token for token; only when the rows call tools), reasoning_loss and answer_loss (either side of the thinking block; only when the rows carry reasoning)
llm_instruction instruction, input?, output loss, perplexity
llm_dpo prompt, chosen, rejected accuracy (chosen more likely than rejected), margin
llm_orpo the same accuracy, margin (log-odds of chosen over rejected)
llm_simpo the same accuracy, margin (mean token log-probability of chosen over rejected)
llm_reward prompt, chosen, rejected accuracy (chosen scored higher), margin (mean score difference)
llm_kto prompt, completion, label desirable_logp, undesirable_logp, gap (desirable minus undesirable, per token)
llm_pretrain text loss, perplexity, nll_per_char (loss per character: comparable across tokenizers)
llm_tokenizer text base_chars_per_token, chars_per_token, token_reduction (share of held-out tokens saved), words
llm_quantize messages loss, perplexity, base_loss, loss_delta (quantized minus base), size_mb
llm_merge messages loss, perplexity, base_loss, loss_delta (merged minus base)
llm_agent_rl prompt + what the reward reads (answer) reward (greedy trajectories), turns, tool_calls, malformed, answered
llm_grpo prompt + what the reward reads (answer) reward (greedy completion)
llm_distill prompt teacher_logp (teacher's mean token log-probability of the greedy answer), reverse_kl (student minus teacher on those tokens)
llm_decision_sft state, questions (Jev records) or state, question, label (one question per row) accuracy, nll, ece
llm_decision_cispo same accuracy, nll, ece, think_accuracy (after reasoning)
embedding_contrastive query, positive, negative? accuracy (positive ranked first), mrr
tts_piper audio (a WAV file), text wer (Whisper on the voice), reference_wer (Whisper on the recordings), duration_ratio, phrase_wer (the worst-heard fixed reply), utmos and dnsmos_sig, dnsmos_bak, dnsmos_ovrl (how clean the voice sounds), speaker_similarity (whose voice it is); reference_ and phrase_ for the recordings and the replies
tts_qwen the same the same
wakeword audio, text? (a positive's must be the phrase) recall, false_accepts, scenario (phrase then command: one firing, on the phrase)

By default every weight is trained, in FP32. training.adapter switches any of these recipes to LoRA:

training:
  adapter: {name: lora, r: 16, alpha: 32, dropout: 0.05}   # omit to train every weight

The base weights stay frozen and only the adapter trains, so the resume snapshot holds just the adapter. Which layers are adapted comes from the architecture (transformer, bi_encoder: the attention projections; hybrid: those plus the linear-attention projections) unless targets names others. The adapter is folded into the base weights when the checkpoint is written, so a LoRA checkpoint and a fully trained one load the same way: a plain save_pretrained directory at output/<experiment>/train/checkpoint/ (and, once validation passed, at output/<experiment>/validate/checkpoint).

quantize: nf4 (or int8) in the adapter section is QLoRA: the frozen base weights load in 4 or 8 bits with bitsandbytes, which needs a CUDA GPU and uv sync --extra quantize (compose.gpu.yaml builds the image with it). The checkpoint is still a plain full-precision model: the adapter is merged into the base reloaded in FP32 on the CPU, never into the 4-bit weights.

training:
  adapter: {name: lora, r: 16, alpha: 32, quantize: nf4}

training.devices: N trains on N GPUs of one server, one process each: every batch is split between them, the gradients are averaged, and the first process writes the log, the snapshots and the checkpoint. batch_size is the whole batch, not a share. The cards are the N after the one the job owns (cuda:0 and up from the CLI). Each process holds the whole model, so this speeds a model that fits one card; it does not fit a larger one.

training: {batch_size: 16, devices: 4}   # 4 rows per GPU per micro-batch

Decision recipes

A decision model answers choice, noul (yes/no) and score questions about a state with a probability per option. The backbone gets a head (model.head) that reads the options, and the prompt marks state, question and options with rare tokens of the Qwen tokenizer:

Head How it scores Origin
readout options get one-token codes (A, B, ...); a linear layer on the decide position scores the codes Jev, jeff
pointer a dot product between the decide position and each option's end position; no codes, any number of options Jeeves

llm_decision_sft trains the head with cross-entropy over the options. With method.think_fraction: 0 that is a one-pass decision model. Above 0, that share of the questions gets a reasoning chain sampled from the base model before training and inserted as context, so the head learns to decide after reasoning as well as without it.

llm_decision_cispo continues from such a checkpoint (model.init): per question it samples group_size reasoning chains, rewards each by the probability the head then gives the right option, and reinforces the chains that beat their group's mean. The head's loss after reasoning and without reasoning is added, so the one-pass answer keeps working.

Both keep method.calibration of the training records aside, fit one temperature on them when training ends and store it with the head (head.json in the checkpoint); validation reports calibrated accuracy, nll and ece. The training unit is the question: training.batch_size counts questions, however many a record holds, and CISPO forwards batch_size x group_size sequences per step.

model:
  head: pointer
  init: output/decision-pointer-qwen3.5-0.8b-<hash>/validate/checkpoint   # stage 2 starts where stage 1 ended

model.init works in every llm_* and embedding_* recipe: it is "start from this checkpoint of an earlier experiment". Point it at <experiment>/validate/checkpoint: that link exists only when the earlier experiment passed validation, whereas train/checkpoint is there even when it failed. The checkpoint counts by content in the fingerprint, so rebuilding the earlier experiment rebuilds the one that continues it.

Text-to-speech recipe (tts_piper)

tts_piper fine-tunes a Piper voice: a VITS model that reads espeak-ng phonemes and runs anywhere as one ONNX file. Piper is GPL-3.0-or-later and pins its own torch, so it is not a dependency of this package: it lives in environment/piper/ (its own pyproject.toml and uv.lock), and both speech recipes run it as a separate process through PIPER_PYTHON. Build it once, and again after pulling: the script is safe to repeat.

environment/piper/setup.sh       # uv sync of the locked environment, plus the monotonic_align extension the wheel lacks
model:
  quality: medium                  # must match the starting checkpoint; high sets Piper's larger decoder
  espeak_voice: en-gb-x-rp
  init:                            # a Piper .ckpt on the Hub (pinned), a local .ckpt, or <experiment>/validate/checkpoint
    name: jgkawell/jarvis
    revision: 37f8763122312665f091d1fc760abaf1f79b02cc
    file: en/en_GB/jarvis/medium/checkpoints/epoch=8338-step=2012650.ckpt
data:
  sources:
    - {name: delimited, path: recordings/metadata.csv, audio_dir: recordings/wavs}   # `<file>|<text>` lines
training: {epochs: 100, batch_size: 16, args: {model.learning_rate: 1.0e-4}}         # args: any other `piper.train fit` option
validation: {asr: {language: en}, max: {wer: 0.15}}
  • Rows. audio and text. A relative audio path starts at the source's audio_dir, else at the directory of the source file, and an LJSpeech id without .wav finds its file. delimited reads a headerless | file such as an LJSpeech or Piper metadata.csv; local_jsonl reads the rows.jsonl that data_speech writes. The audio directories count in the data fingerprint, so re-recorded files rebuild the experiment.
  • Training. The starting weights are copied in (model.warmstart_ckpt: every tensor whose shape matches, a fresh optimizer), so an old Piper checkpoint loads into the current trainer and training.epochs counts from zero. Piper's own validation split is off; the validate stage measures on this recipe's held-out rows instead. A rerun after a crash continues from Piper's last epoch snapshot (run/lightning_logs/version_<n>/checkpoints/last.ckpt); Piper turns cuDNN determinism off, so a GPU run is not bit-identical to an earlier one. fit.log holds Piper's output; a cancelled API job stops when the fit ends.
  • Checkpoint. <experiment>/validate/checkpoint/ holds voice.onnx and voice.onnx.json (a Piper voice: piper -m voice.onnx, Home Assistant's /share/piper) and model.ckpt, the start of a further fine-tune.
  • Validation. The voice reads up to validation.rows held-out texts; Whisper (validation.asr, pinned) transcribes them and the original recordings. reference_wer is the floor wer can reach with that recognizer; numbers written as digits in one text and as words in the other count as errors.
  • Sound quality. Whisper only says whether a voice is understood, not whether it sounds clean, so every clip is also scored by two predictors that need no reference recording, each from 1 to 5: utmos (naturalness, UTMOS22 strong, MIT, as ONNX from TigreGotico/utmos-onnx) and DNSMOS P.835 (Microsoft DNS Challenge, CC BY 4.0): dnsmos_sig (distortion of the speech itself), dnsmos_bak (background noise) and dnsmos_ovrl (overall). The same metrics with reference_ score the recordings, and with phrase_ the kept replies; each take in phrases.json carries its own. Both models run on onnxruntime, the UTMOS one (411 MB) from the Hub cache and DNSMOS downloaded into validate/models/ and checked against its pinned hash. Bound them like any metric, e.g. min: {dnsmos_bak: 3.0}.
  • Speaker similarity. speaker_similarity is the cosine of a clip's x-vector to the mean x-vector of up to 50 of the recordings, from a speaker verification model none of the voices trained on (microsoft/wavlm-base-plus-sv, pinned). Another take of the same voice scores about 0.97 and another person about 0.4; it is what tells a clone that sounds clean but no longer like the voice.
  • Fixed replies. validation.phrases (name: text) are the lines the agent plays as they are, such as the read-back before a task (web_search: Searching Reference.). Piper's noise makes every take of a text different, so each is spoken validation.phrase_takes times (default 3) and the take Whisper hears best becomes <experiment>/validate/phrases/<name>.wav; phrases.json lists every take and what was heard, and phrase_wer is the worst kept take, so a voice that garbles one reply fails validation. Changing a reply reruns only validate; a rerun keeps the takes it has, so the agent plays the file that passed.
  • Reply filter. validation.phrase_filter is an ffmpeg audio filter chain tried on every take (needs ffmpeg on PATH): highpass=f=80,afftdn=nf=-20 takes out the hiss a Piper voice carries. Each take is then heard and scored raw and filtered, and the kept file is the best-heard of both, a filtered one when they tie: denoising can blur a name ("JARVIS" heard as "jobless"), and then that reply stays raw. phrases.json says per take which file it was (filtered, file). Filtering the training recordings instead made a worse voice: it learns the filter's artefacts.

Before training on a voice, read its terms. jgkawell/jarvis is MIT on its weights per its model card, but it imitates a film character played by a real actor; MIT does not cover the voice, so treat it as personal and non-commercial.

Voice cloning recipe (tts_qwen)

tts_qwen trains nothing: Qwen3-TTS (Apache-2.0) reads any text in a voice it hears once, from one recording and its exact text. The recipe picks that recording and puts the clone through the same listening test as tts_piper. It runs Qwen3-TTS on MLX, so it needs Apple silicon; the program lives in environment/qwen/ (its own pyproject.toml and uv.lock) and runs as a separate process through QWEN_PYTHON.

environment/qwen/setup.sh        # uv sync of the locked environment
model:                             # a Qwen3-TTS Base model on MLX; only Base models clone
  name: mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16
  revision: a6eb4f68e4b056f1215157bb696209bc82a6db48
reference: {min_seconds: 4, max_seconds: 12}
data:
  sources:
    - {name: local_jsonl, path: output/jarvis-lines-<hash>/select/rows.jsonl}
validation: {asr: {language: en}, max: {wer: 0.1}, min: {utmos: 3.5, speaker_similarity: 0.9}}
  • Reference. Of the training recordings between reference.min_seconds and max_seconds, the one UTMOS rates most natural (the first of a tie) is the voice. A clone copies the clip's sound along with its voice, so the clip is used as recorded: a denoised clip cloned worse (UTMOS 3.90 against 4.07, speaker similarity 0.943 against 0.965).
  • Checkpoint. <experiment>/validate/checkpoint/ holds reference.wav and voice.json (model, revision, reference_text, sample_rate): everything a player needs to load the model and speak in the voice.
  • Takes. Every take draws from a seed made of pipeline.seed, the text and the take number, and MLX is bit-identical under a seed, so a rerun speaks the same files. Generation is batched: one model load per stage run. Validation, fixed replies and the reply filter work as in tts_piper.
  • Size and speed. The 1.7B model is 4.2 GB in bf16 and speaks about twice as fast as real time on an M-series Mac; the 0.6B model (2.4 GB) scored the same on these metrics.

Wake word recipe (wakeword)

wakeword trains the detector that wakes a voice agent: a small model that listens to the microphone all the time and fires when it hears one phrase. It follows openWakeWord (Apache-2.0): audio becomes a melspectrogram and then a 96-number speech embedding every 80 ms through two frozen ONNX models (pinned by hash, downloaded once), and a small dnn head scores the last 16 embeddings (2 s). Only the head is trained, so a few hundred synthetic recordings are enough, and the exported file loads in openWakeWord's runtime.

phrase: Jarvis                     # the wake word: the agent's name; what follows it is the command
data:
  positive: [{name: local_jsonl, path: output/wake-phrase-<hash>/select/rows.jsonl}]     # data/wake_phrase.yaml
  negative: [{name: local_jsonl, path: output/wake-negatives-<hash>/select/rows.jsonl},  # data/wake_negatives.yaml
             {name: local_jsonl, path: output/wake-nearmiss-<hash>/select/rows.jsonl}]   # data/wake_nearmiss.yaml
  background: []                   # directories of noise WAVs; empty: Gaussian noise
augment: {rounds: 4, followed: 2, noise: 200, snr: [5, 25]}            # followed: "Jarvis" + a command, as negatives
validation: {threshold: 0.5, min: {recall: 0.9, scenario: 0.9}, max: {false_accepts: 0.05}}
  • Phrase. The wake word is the agent's name; the command after it ("Jarvis, wake up", "Jarvis, search the web for ...") is not part of it, and speech recognition hears it once the detector has fired. Positives say the phrase and nothing else (a positive row with a text must say it); negatives must never contain it, and the data stage stops on one that does. The hardest negatives are near misses ("Travis", "Davis", "Karen"), and the commands said alone. The model file is named after the phrase: jarvis.onnx.
  • Recordings. data_speech with a multi-speaker voice (voice.speakers) and select.per_text: null makes them: the name read by hundreds of speakers, each checked by Whisper. Recordings of the people who will use the agent (delimited or local_jsonl) belong in positive too: synthetic voices are a start, not the target.
  • Windows. Every training recording becomes augment.rounds 2-second windows over noise at a random SNR and gain, ending up to augment.tail seconds after the speech. Negatives are also cut short at random, so half a phrase is a negative; augment.followed windows per phrase put other speech after it, so the detector fires as the name ends and not again while the command that follows is spoken. All randomness comes from the seed and the recording.
  • Training. Balanced batches (half positive), BCE, Adam, training.steps steps on the CPU, seeded: a rerun after a crash trains the same detector again.
  • Validation. Held-out recordings in noise (validation.snr), scored every 80 ms as the runtime scores them, a firing counted at most once a second: recall and false_accepts per recording, and scenario, the agent's first moment: "Jarvis", a pause, then another held-out utterance (a command such as "Please search to the paper reference on the web", a near miss, or ordinary speech). It passes when the detector fires exactly once, while or just after the name is said.
  • Checkpoint. <experiment>/validate/checkpoint/ holds jarvis.onnx, the two feature models and wakeword.json (the phrase, the files, the feature models' hashes):
from openwakeword.model import Model      # pip install openwakeword
detector = Model(wakeword_models=["checkpoint/jarvis.onnx"], inference_framework="onnx",
                 melspec_model_path="checkpoint/melspectrogram.onnx", embedding_model_path="checkpoint/embedding_model.onnx")
score = detector.predict(chunk)["jarvis"]   # chunk: 1280 samples, 16 kHz, int16; wake at >= the threshold

false_accepts is measured on the negatives you give, not on hours of real audio; openWakeWord's own models add a large corpus of everyday speech and music for that. Add real background recordings and your own speech before trusting the rate. openWakeWord's code is Apache-2.0 and its README puts its pre-trained models under CC BY-NC-SA 4.0; the two feature models are derived from Google's Apache-2.0 speech_embedding, so treat a detector as non-commercial until you have checked their terms.

A voice agent from these recipes

The four configs below build what a voice agent says and hears; what it decides stays in the agent.

Moment What runs Built by
User: "Jarvis, wake up" the wake word detector fires as "Jarvis" ends, once wakeword (wakeword/jarvis.yaml)
speech to text hears the command after it ("wake up"), and the agent's model picks the command wake_up the agent (an LLM recipe can tune it)
Agent: ready plays the read-back of wake_up, phrases/wake_up.wav tts_qwen validation.phrases
User: "Please search to the paper reference on the web" speech to text, then the agent's model picks web_search the agent
Agent: "Searching Reference.", then works plays phrases/web_search.wav, then runs the search tts_qwen validation.phrases

tts/qwen_jarvis.yaml holds nine read-backs: wake_up (At your service, sir.), web_search (Searching Reference.), horizontal_task (Processing Long Task.), remember_pattern (Checking the Pattern.), analysis (Target. Analysis.: a full stop so the voice pauses), focus_mode_enter (Activate Focus mode.), focus_mode_quit (Deactivate Focus mode.), persona_karen (Change to KAREN.) and persona_jarvis (Change to JARVIS.). The voice's training set (data/speech.yaml) reads them too. Which utterance means which command is the agent's to decide: no recipe here maps "enter the focus mode" to focus_mode_enter yet.

04. Extending

Each family is a model catalog crossed with training methods; the two grow independently.

modeling
├── llm
│   ├── Model Catalog      transformer, hybrid            <- add an architecture
│   │   └── heads          readout, pointer               <- add a decision head
│   └── Training Method    sft, instruction, dpo, grpo    <- add a method
│       └── decision       sft, cispo
├── embedding
│   ├── Model Catalog      bi_encoder
│   └── Training Method    contrastive
├── tts                    one recipe, trained by the Piper program; `model.quality` picks its size
└── wakeword               one recipe: openWakeWord's dnn head on its frozen features

A new architecture in a catalog

Subclass the family's backbone and register it; every recipe of the family can then name it in model.architecture.

@LLM_BACKBONES.register("moe")                  # modeling/llm/models/moe_backbone.py; import it in models/__init__.py
class MoEBackbone(LLMBackbone):
    loader = AutoModelForCausalLM               # the transformers class that builds it
    frozen = ("router",)                        # parameter-name fragments training must not update
    adapter_targets = ("q_proj", "v_proj")      # layers LoRA adapts by default
    markers = Markers(...)                      # only for decision recipes: seven single tokens of its tokenizer

An embedding architecture (for example a cross-encoder) subclasses EmbeddingBackbone the same way and overrides what differs, such as pool.

A new decision head

A head owns the prompt it reads and turns hidden states into one score per option. The tokens that mark state, question and options come from the architecture (LLMBackbone.markers), so a head works on any backbone that defines them.

@HEADS.register("my_head")                      # modeling/llm/models/heads/my_head.py; import it in heads/__init__.py
class MyHead(DecisionHead):
    def option_block(self, options): ...        # how the options appear in the prompt
    def scores(self, hidden, layouts): ...      # (rows, options) scores from (rows, tokens, hidden) states

A new training method in a family

A method says what a row must contain, how a batch becomes a loss and how the result is measured. Then one config class and one registered pipeline make it a recipe.

class MyMethod(LLMMethod):                      # modeling/llm/methods/my_method.py
    class Config(BaseModel):                    # the YAML's `method:` section
        strength: float = 1.0

    def check(self, row): ...                   # raise ValueError for a row this method cannot use
    def loss(self, backbone, rows): ...         # differentiable scalar for one batch
    def evaluate(self, backbone, rows, batch_size, progress): ...   # metrics the validation bounds can name

class MyConfig(LLMConfig):                      # modeling/llm/llm_config.py
    method_class = MyMethod
    method: MyMethod.Config = Field(default_factory=MyMethod.Config)

@recipe                                         # modeling/llm/llm_pipeline.py
class MyPipeline(LLMPipeline):
    kind = "llm_my_method"
    config_class = MyConfig

prepare(backbone, rows, workdir) runs once before training with the untouched base weights (DPO caches its reference margins there). Helpers shared by LLM methods live in modeling/llm/methods/llm_method.py.

A new family

A package with models/, methods/, a TuningConfig subclass that sets backbones to its catalog, and a TuningPipeline subclass; import it in modeling/__init__.py. The data, train and validate stages and the training loop are inherited.

Reusing sampled groups (llm_grpo, llm_decision_cispo)

Sampling is the expensive part of both methods. method.iterations takes that many optimizer steps on each sampled group before sampling again. From the second step on the policy has moved away from the one that produced the samples, and method.clip bounds how far: GRPO clips the policy ratio to 1 +- clip (PPO's surrogate), CISPO caps the importance weight of each reasoning token at 1 + clip. llm_grpo also takes method.beta, a KL penalty towards the base model; it needs training.adapter, because the base model is then the same weights with the adapter switched off and no second model is loaded.

loss_norm: token averages the clipped objective over every graded token of the batch instead of per completion first, so long completions are not weighed down (Dr. GRPO, DAPO); scale_rewards: false keeps the advantages unscaled by the group's standard deviation; reward_workers scores a batch's completions on that many threads, for a judge reward.

policy_loss picks the objective on a reused group: ppo (the default) clips each token's ratio, gspo clips one ratio per completion, the exponent of its mean token log-ratio (Qwen3), and cispo caps each token's ratio from above and uses it as a weight without gradient, so clipped tokens still learn (MiniMax-M1). clip_high widens only the upper bound (DAPO's clip-higher). On a group's first use the ratio is 1 and all three are the same plain policy gradient, so they differ only with iterations above 1; the clipped metric then says how much of each reused step was cut off.

method: {group_size: 4, iterations: 2, clip: 0.2, beta: 0.05}
training: {adapter: {name: lora, r: 16, alpha: 32}}

Extending the tokenizer (model.tokenizer)

Every llm_* training recipe takes words to make single tokens, control tokens, and a chat template to use instead of the model's. They are applied when the base model (or model.init) is loaded and saved with the checkpoint, so the next recipe and the server see the same tokens. llm_tokenizer picks the words from a corpus.

model:
  tokenizer:
    tokens_file: output/tokenizer-ko-qwen3-f9a813ad/train/tokens.txt   # or tokens: [...]
    special_tokens: ["<calc>"]
    chat_template: configs/llm/my_template.jinja

Words are added as BPE merges after the tokenizer's own, each at the start of a text and after a space, so other text tokenizes as before. A new embedding row starts as the mean of the rows of the pieces it joins. Under a LoRA adapter those rows train beside it; full fine-tuning trains them anyway.

A reward for llm_grpo

A trained reward model needs no code: reward: {name: reward_model, path: <an llm_reward checkpoint>}, alone or as a part of weighted. A rule is a registered function:

@REWARDS.register("json_valid")                 # modeling/llm/methods/grpo_reward.py
def json_valid(completion: str, row: dict, params: dict) -> float: ...

An environment for llm_agent_rl

An environment lists its tools as schemas and answers each call with text; an error is returned as text too, so the policy can read it and try again. Generated calls are untrusted input: parse arguments, never run them.

@ENVIRONMENTS.register("weather")               # modeling/llm/agent/agent_environment.py
class Weather(Environment):
    name = "weather"
    tools = property(lambda self: [function("forecast", "Tomorrow's weather", {"city": "A city name"})])
    def handlers(self): return {"forecast": lambda city: f"sunny in {city}"}

A recipe with its own stages

When training does not fit the shared loop (for example an external trainer run as a child process), a recipe brings its own config class, stages and pipeline class; users only see a new value for pipeline.recipe.

class MyConfig(ModelingConfig):                 # modeling/<recipe>/<recipe>_config.py
    model: MyModelConfig

class MyTrain(TrainStage[MyConfig]):            # modeling/<recipe>/<recipe>_stages.py
    sections = ("seed", "model")                # config sections that decide the result
    def train(self, workdir, inputs):           # write into workdir; must be safe to run again after a crash
        ...
        return {"checkpoint": str(workdir / "model")}

class MyValidate(ValidateStage[MyConfig]):
    def measure(self, workdir, inputs):         # metrics on held-out data; bounds and report are handled for you
        return {"accuracy": ...}

@recipe                                         # modeling/<recipe>/<recipe>_pipeline.py; import it in modeling/__init__.py
class MyPipeline(ModelingPipeline[MyConfig]):
    kind = "my_recipe"
    config_class = MyConfig
    stage_classes = (MyTrain, MyValidate)       # execution order; a stage may only require earlier ones

Inside a stage, self.progress.update(done, total, note) drives the progress bar.

Building blocks a new recipe reuses

A data, evaluation or modeling recipe is safe to rerun because its stages write through these. Use them rather than writing the step again, so the guarantee holds in one place:

Need Use Where
Write a file whole or not at all with atomic_path(path) as temporary: core/file_store.py
Build a checkpoint directory whole or not at all with staged_directory(workdir / "checkpoint") as staging: core/file_store.py
Rows between stages read_jsonl, write_jsonl (atomic); the data channel's read_rows also reads a missing file as empty core/file_store.py, data/row_store.py
Per-item results that survive a crash cached(directory, key), keep(directory, key, row) under a natural key(...) data/row_store.py
Hold out validation rows held_out_positions(count, seed, share): a row's side depends only on the seed and its position modeling/modeling_stages.py
Load a model for one block with backbone.loaded(device, checkpoint): releases it afterwards, also on failure modeling/tuning/tuning_backbone.py
A name: key in the config a Registry; in the config validator REGISTRY.check(*names) gives the standard error core/recipe_registry.py
Local files a config reads local_paths(entries, keys) in paths(), so the API can confine them modeling/modeling_config.py
Recordings named by rows audio.recordings(source), audio.signatures(sources) for the fingerprint modeling/tts/audio_file.py

05. Tests

uv run pytest tests/unit                # one module of a feature at a time: pure functions, a method on the tiny model
uv run pytest tests/integ               # each feature end to end: runner, API, every recipe on a tiny random model, fakes for teachers, Piper, Whisper
uv run ruff check . && uv run mypy      # what CI runs on every pull request, besides the tests

Licence: MIT. Models and datasets a config names keep their own licences.

Used by

Contributors

Languages