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.
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.
gitand 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: transformerconfigs only. - Disk for the model cache: Qwen3.5-0.8B and Qwen3-0.6B take about 1.5 GB each under
~/.cache/huggingface.
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 keysThe 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.
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"]
}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.6BThe 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.
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.-
Write your rows as JSON Lines in the recipe's shape (
samples/README.mdlists every shape). Forllm_sft, one conversation per line:{"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]} -
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 ahuggingfacesource, as inconfigs/llm/grpo_gsm8k.yaml);training: removemax_stepsfor a full run, setepochs,lr,batch_size;validation.max: the held-out loss your model must reach to be handed on.
-
uv run llmclick validate configs/llm/my_model.yaml, thenuv 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.
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 resultOr 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.
| 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) |
| 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.
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.
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.
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.
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 aversion; 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.ptin 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. Withmethod.iterationsabove 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_dirandtrackerare 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: Nmeasures the model on the held-out rows every N steps with the validate stage's own metrics (training.eval_rowscaps how many rows), and records them aseval/<metric>next to the loss intraining.jsonland in the tracker. The weights are as they are at that step: for the decision recipes the temperature is not fitted yet, sonllandecethere are uncalibrated. - Validation. In the Modeling channel
validatefollowstrainand cannot be left out. It measures the trained model on data training never saw and checksvalidation.min/validation.max; a miss, or a metric that is not a finite number, stops the pipeline. A later stage reads the checkpoint fromvalidate, never fromtrain. Without bounds the metrics are only recorded, and a warning says so. - Tracking. With
tracker.enabledeach training step is sent to wandb as it happens; a run interrupted and resumed continues the same wandb run.trackeris 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 markedinterruptedand 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_WORKERSjobs 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, setAPI_TOKEN(every/apicall then needsAuthorization: Bearer <token>) andAPI_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:ortraining.learning_rateis an error instead of a silently ignored setting.
| 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 weightThe 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-batchA 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 endedmodel.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.
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 lacksmodel:
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.
audioandtext. A relativeaudiopath starts at the source'saudio_dir, else at the directory of the source file, and an LJSpeech id without.wavfinds its file.delimitedreads a headerless|file such as an LJSpeech or Pipermetadata.csv;local_jsonlreads therows.jsonlthatdata_speechwrites. The audio directories count in thedatafingerprint, 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 andtraining.epochscounts from zero. Piper's own validation split is off; thevalidatestage 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.logholds Piper's output; a cancelled API job stops when the fit ends. - Checkpoint.
<experiment>/validate/checkpoint/holdsvoice.onnxandvoice.onnx.json(a Piper voice:piper -m voice.onnx, Home Assistant's/share/piper) andmodel.ckpt, the start of a further fine-tune. - Validation. The voice reads up to
validation.rowsheld-out texts; Whisper (validation.asr, pinned) transcribes them and the original recordings.reference_weris the floorwercan 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 fromTigreGotico/utmos-onnx) and DNSMOS P.835 (Microsoft DNS Challenge, CC BY 4.0):dnsmos_sig(distortion of the speech itself),dnsmos_bak(background noise) anddnsmos_ovrl(overall). The same metrics withreference_score the recordings, and withphrase_the kept replies; each take inphrases.jsoncarries its own. Both models run on onnxruntime, the UTMOS one (411 MB) from the Hub cache and DNSMOS downloaded intovalidate/models/and checked against its pinned hash. Bound them like any metric, e.g.min: {dnsmos_bak: 3.0}. - Speaker similarity.
speaker_similarityis 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 spokenvalidation.phrase_takestimes (default 3) and the take Whisper hears best becomes<experiment>/validate/phrases/<name>.wav;phrases.jsonlists every take and what was heard, andphrase_weris the worst kept take, so a voice that garbles one reply fails validation. Changing a reply reruns onlyvalidate; a rerun keeps the takes it has, so the agent plays the file that passed. - Reply filter.
validation.phrase_filteris an ffmpeg audio filter chain tried on every take (needsffmpegon PATH):highpass=f=80,afftdn=nf=-20takes 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.jsonsays 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.
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 environmentmodel: # 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_secondsandmax_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/holdsreference.wavandvoice.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 intts_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.
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
textmust say it); negatives must never contain it, and thedatastage 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_speechwith a multi-speaker voice (voice.speakers) andselect.per_text: nullmakes them: the name read by hundreds of speakers, each checked by Whisper. Recordings of the people who will use the agent (delimitedorlocal_jsonl) belong inpositivetoo: synthetic voices are a start, not the target. - Windows. Every training recording becomes
augment.rounds2-second windows over noise at a random SNR and gain, ending up toaugment.tailseconds after the speech. Negatives are also cut short at random, so half a phrase is a negative;augment.followedwindows 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.stepssteps 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:recallandfalse_acceptsper recording, andscenario, 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/holdsjarvis.onnx, the two feature models andwakeword.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 thresholdfalse_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.
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.
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
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 tokenizerAn embedding architecture (for example a cross-encoder) subclasses EmbeddingBackbone the same way and overrides
what differs, such as pool.
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) statesA 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 = MyConfigprepare(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 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.
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}}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.jinjaWords 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 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 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}"}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 onesInside a stage, self.progress.update(done, total, note) drives the progress bar.
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 |
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 testsLicence: MIT. Models and datasets a config names keep their own licences.