Modality II (v0.52.0)

7 parts. New task families + BitNet + EBFT/GDPO + MoE quant. +237 tests (6968 → 7205).

TTS (task: tts)

5 family allowlists with per-family emotion vocabularies.

yaml
task: tts
modality: audio_out
base: canopylabs/orpheus-3b-0.1-ft
data:
  format: audio
  train: ./data/tts.jsonl
training:
  tts_family: orpheus   # orpheus | sesame_csm | llasa | spark | oute
  tts_emotion: happy    # a single string, not a list

Cross-validator gates per family. canopylabs/orpheus requires tts_family: orpheus.

Only two families take an emotion tag, and each has its own list. Orpheus accepts neutral, happy, sad, angry, excited, calm, whisper and laugh; Oute accepts neutral, happy, sad, angry, calm and excited. Llasa, Spark and Sesame-CSM refuse tts_emotion outright (tts_family='llasa' does not support emotion conditioning), and a tag outside a family's list is refused when the config loads (laugh on oute, for instance). For the two families that take one, the trainer prepends a family-specific control string to the first user turn of every row: <|emotion|>happy<|/emotion|> for Orpheus and [emotion: happy] for Oute.

data.format: audio is the live-codec path, and it needs a per-family package that Soup does not install for you: snac for Orpheus, moshi for Sesame-CSM, xcodec2 for Llasa, sparktts for Spark, outetts for Oute. Only Orpheus is live; the other four raise a friendly per-family error naming what is missing. The path that runs everywhere is the pre-encoded one: encode your audio to codec tokens yourself, register them through data.new_special_tokens, and train with data.format: chatml (or auto) over the token text, as rows with a messages list. chat is not a value of data.format, even though Soup's own error message for a missing codec package says it is. That is the workflow to reach for unless you specifically want the Orpheus codec in-process.

Distillation (task: distill)

yaml
task: distill
base: meta-llama/Llama-3.2-3B
training:
  teacher_model: meta-llama/Llama-3.1-70B-Instruct
  distill_divergence: kl         # kl | forward_kl | reverse_kl | js
  distill_temperature: 2.0       # [0.05, 100]

kl is an alias for forward_kl, which is also what an unset distill_divergence resolves to; an unset distill_temperature resolves to 2.0. distill_mode: sequence (the teacher generates a completion per prompt and the student trains with plain cross-entropy on it, instead of matching logits) cannot be combined with uld_strategy or minillm_enabled: the schema accepts the pair, and the distill trainer refuses it at setup, before any model loads.

Classifier / Reranker / Cross-Encoder

yaml
task: classifier   # | reranker | cross_encoder
training:
  num_labels: 5
  label_names: ["very_bad", "bad", "neutral", "good", "very_good"]
  classifier_kind: single_label   # or multi_label (v0.52.0)
  classifier_lora: false          # true wraps the head with LoRA (v0.71.12)

classifier_kind picks the head: single_label is the default for task: classifier, multi_label trains one independent decision per label rather than a choice between them. classifier_lora is opt-in: it wraps the sequence-classification head as a PEFT SEQ_CLS adapter instead of full fine-tuning, and the ordinary training.lora block still supplies r / alpha / dropout when you turn it on.

BitNet 1.58-bit (schema and export only)

yaml
training:
  quantization: bitnet_1.58

The schema accepts this value and gates it: non-MLX, text modality, task one of sft, pretrain or dpo. It does not train at v0.75.2. The config validates, and then the quantization loader that the SFT, preference, GRPO, PPO, pretrain, reward-model and embedding trainers call rejects the value with unknown quantization 'bitnet_1.58' (reproduced against build_quantization_config_for_loader; no GPU run was made). The BitNetTrainerWrapper that was meant to take over has no caller in soup train, and its own setup() raises a "not validated on this platform" error even when the onebitllms package is importable. The falcon-e-bitnet-sft recipe sets this key, so it fails at the same point (read for the SFT trainer: after the tokenizer loads, before the model does).

The export side is separate and is wired: soup export --format bitnet or tq1_0 runs llama.cpp's converter and then llama-quantize with the TQ1_0 type (it needs a built llama.cpp). is_bitnet_model is a name heuristic that matches a few prefixes (bitnet, falcon-e, falcone, 1bitllm, onebit), not a check of the weights.

EBFT + GDPO variants

These two cannot share a config: ebft_variant is refused unless task: sft and gdpo_variant unless task is dpo or preference.

yaml
task: sft
training:
  ebft_variant: structured        # structured | strided
yaml
task: dpo
training:
  gdpo_variant: length_normalized # standard | length_normalized | margin

ebft_temperature is the softmax temperature of the EBFT energy term (a temperature-scaled cross-entropy over the labelled positions; nothing is sampled). It is bounded to 0.0001 through 100 (outside that range the config is refused) and is refused unless ebft_variant is set.

MoE expert quant

yaml
training:
  moe_lora: true                 # required: moe_expert_quant is refused without it
  moe_expert_quant: nf4          # nf4 | int8_rowwise
  train_router_only: true        # freeze experts, train router only

Reasoning effort dispatch

yaml
training:
  reasoning_effort: medium       # low | medium | high  (gpt-oss family)
  train_on_eot: true             # also train on the end-of-turn token

Both keys are refused unless task is one of sft, pretrain, distill, classifier, reranker or cross_encoder: on dpo the config fails with training.reasoning_effort='high' requires task in [...]; got task='dpo'. reasoning_effort prepends <|reasoning_effort|>medium<|/reasoning_effort|> to the first system message of every row, and inserts a system message at the top when the row has none. train_on_eot extends the assistant-only loss mask to include the end-of-turn token that follows each assistant reply, so the model is trained to emit the terminator; it acts through the assistant-only masking path, which is on by default (data.train_on_responses_only: true), and with that turned off it has nothing to extend.

v0.52.0 shipped the schema; v0.53.2 lit up the trainers — task: distill (DistillTrainerWrapper with KL/forward_kl/reverse_kl/js), task: classifier | reranker | cross_encoder (single-label / multi-label / cross-encoder heads), EBFT + GDPO loss kernels, and reasoning_effort dispatch. See v0.53.2 — Modality II live.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.