mirror of https://github.com/razor-ai/soup.git
5022 lines
188 KiB
Python
5022 lines
188 KiB
Python
"""Pydantic schemas for soup.yaml config — single source of truth."""
|
||
|
||
import re
|
||
from typing import Any, Dict, List, Literal, Optional, Union
|
||
|
||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||
|
||
# v0.39.0 Part C — per-pattern LoRA rank/alpha bounds
|
||
_MAX_LORA_RANK_PATTERN_KEYS = 256
|
||
_MAX_LORA_RANK_PATTERN_VALUE = 1024
|
||
|
||
|
||
class LoraConfig(BaseModel):
|
||
r: int = Field(default=64, description="LoRA rank")
|
||
alpha: int = Field(default=16, description="LoRA alpha")
|
||
dropout: float = Field(default=0.05, description="LoRA dropout")
|
||
target_modules: Union[str, List[str]] = Field(
|
||
default="auto",
|
||
description="Target modules for LoRA. 'auto' = let peft decide.",
|
||
)
|
||
use_dora: bool = Field(
|
||
default=False,
|
||
description="Enable DoRA (Weight-Decomposed Low-Rank Adaptation)",
|
||
)
|
||
use_rslora: bool = Field(
|
||
default=False,
|
||
description="Enable rank-stabilized LoRA scaling (better for high ranks)",
|
||
)
|
||
use_vera: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable VeRA (Vector-based Random Matrix Adaptation). "
|
||
"Shared random matrices — much smaller memory than LoRA. "
|
||
"Mutually exclusive with use_dora and use_olora."
|
||
),
|
||
)
|
||
use_olora: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable OLoRA (Orthogonal LoRA init via QR decomposition). "
|
||
"Passes init_lora_weights='olora' to peft. "
|
||
"Mutually exclusive with use_dora and use_vera. "
|
||
"Equivalent to init_strategy='olora'."
|
||
),
|
||
)
|
||
rank_pattern: Optional[Dict[str, int]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Per-target-module-pattern LoRA rank override. Maps module name "
|
||
"patterns (e.g. 'q_proj', 'experts.*.w1') to integer rank values. "
|
||
"Useful for MoE configs where expert FFNs need lower rank than attn. "
|
||
"Incompatible with use_vera (VeRA shares one rank across modules)."
|
||
),
|
||
)
|
||
alpha_pattern: Optional[Dict[str, int]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Per-target-module-pattern LoRA alpha override. Maps module name "
|
||
"patterns to integer alpha values. Pairs with rank_pattern. "
|
||
"Incompatible with use_vera."
|
||
),
|
||
)
|
||
init_strategy: Literal["random", "pissa", "olora", "loftq"] = Field(
|
||
default="random",
|
||
description=(
|
||
"LoRA init strategy. 'random' (default) is standard Kaiming init. "
|
||
"'pissa' (PiSSA) initializes A/B from the SVD of the base weight — "
|
||
"faster early convergence but adds an SVD pass on the first epoch. "
|
||
"'olora' is equivalent to use_olora=True (orthogonal QR init). "
|
||
"'loftq' (v0.41.0) initialises A/B + a low-bit base together, "
|
||
"useful with QLoRA. Cannot be combined with use_dora or use_vera."
|
||
),
|
||
)
|
||
# v0.41.0 Part C — LoftQ tuning knobs (used only when init_strategy='loftq').
|
||
loftq_iter: int = Field(
|
||
default=1, ge=1, le=10,
|
||
description=(
|
||
"LoftQ iteration count (1-10). Higher = better quant-aware init "
|
||
"at the cost of one-time setup latency. Used only when "
|
||
"init_strategy='loftq'."
|
||
),
|
||
)
|
||
loftq_bits: Literal[2, 4, 8] = Field(
|
||
default=4,
|
||
description=(
|
||
"LoftQ target bitwidth — must be one of {2, 4, 8}. Used only "
|
||
"when init_strategy='loftq'."
|
||
),
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_peft_exclusivity(self) -> "LoraConfig":
|
||
enabled = [
|
||
name for name, value in (
|
||
("use_dora", self.use_dora),
|
||
("use_vera", self.use_vera),
|
||
("use_olora", self.use_olora),
|
||
)
|
||
if value
|
||
]
|
||
if len(enabled) > 1:
|
||
raise ValueError(
|
||
f"PEFT methods are mutually exclusive, got multiple enabled: "
|
||
f"{', '.join(enabled)}. Pick at most one of use_dora, "
|
||
f"use_vera, use_olora."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="before")
|
||
@classmethod
|
||
def _backcompat_align_olora(cls, values):
|
||
"""Back-compat: pre-validation, align init_strategy='olora' when only use_olora was set."""
|
||
if not isinstance(values, dict):
|
||
return values
|
||
# Copy to avoid mutating the caller's dict (matches v0.33.0 #47
|
||
# CrossDocCollator immutability fix).
|
||
if values.get("use_olora") and "init_strategy" not in values:
|
||
values = dict(values)
|
||
values["init_strategy"] = "olora"
|
||
return values
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_init_strategy(self) -> "LoraConfig":
|
||
# use_olora=True must agree with init_strategy when both are explicit
|
||
if self.use_olora and self.init_strategy != "olora":
|
||
raise ValueError(
|
||
f"use_olora=True conflicts with init_strategy={self.init_strategy!r}. "
|
||
f"Either set init_strategy='olora' (or omit it), or set use_olora=False."
|
||
)
|
||
# init_strategy='pissa' is incompatible with DoRA / VeRA
|
||
if self.init_strategy == "pissa" and (self.use_dora or self.use_vera):
|
||
other = "use_dora" if self.use_dora else "use_vera"
|
||
raise ValueError(
|
||
f"init_strategy='pissa' is incompatible with {other}=True. "
|
||
f"PiSSA initializes the LoRA pair via SVD; combine with plain LoRA "
|
||
f"(or rsLoRA) only."
|
||
)
|
||
# v0.41.0 Part C — init_strategy='loftq' is incompatible with DoRA / VeRA
|
||
if self.init_strategy == "loftq" and (self.use_dora or self.use_vera):
|
||
other = "use_dora" if self.use_dora else "use_vera"
|
||
raise ValueError(
|
||
f"init_strategy='loftq' is incompatible with {other}=True. "
|
||
f"LoftQ jointly initialises A/B with quantised base weights; "
|
||
f"combine with plain LoRA only."
|
||
)
|
||
return self
|
||
|
||
@field_validator("rank_pattern", "alpha_pattern", mode="before")
|
||
@classmethod
|
||
def _validate_pattern_dict(cls, value) -> Optional[Dict[str, int]]:
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, dict):
|
||
raise ValueError("rank_pattern/alpha_pattern must be a dict[str, int]")
|
||
if len(value) > _MAX_LORA_RANK_PATTERN_KEYS:
|
||
raise ValueError(
|
||
f"rank_pattern/alpha_pattern caps at {_MAX_LORA_RANK_PATTERN_KEYS} keys, "
|
||
f"got {len(value)}"
|
||
)
|
||
cleaned: Dict[str, int] = {}
|
||
for key, val in value.items():
|
||
if not isinstance(key, str) or not key:
|
||
raise ValueError(
|
||
"rank_pattern/alpha_pattern keys must be non-empty strings"
|
||
)
|
||
if "\x00" in key:
|
||
raise ValueError("rank_pattern/alpha_pattern keys cannot contain null bytes")
|
||
if isinstance(val, bool) or not isinstance(val, int):
|
||
raise ValueError(
|
||
f"rank_pattern/alpha_pattern values must be int, "
|
||
f"got {type(val).__name__} for {key!r}"
|
||
)
|
||
if val <= 0 or val > _MAX_LORA_RANK_PATTERN_VALUE:
|
||
raise ValueError(
|
||
f"rank_pattern/alpha_pattern values must be in (0, "
|
||
f"{_MAX_LORA_RANK_PATTERN_VALUE}], got {val} for {key!r}"
|
||
)
|
||
cleaned[key] = val
|
||
return cleaned
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_pattern_vera_exclusivity(self) -> "LoraConfig":
|
||
if self.use_vera and self.rank_pattern:
|
||
raise ValueError(
|
||
"rank_pattern is incompatible with use_vera=True (VeRA shares "
|
||
"a single rank across all target modules). Disable use_vera or "
|
||
"remove rank_pattern."
|
||
)
|
||
if self.use_vera and self.alpha_pattern:
|
||
raise ValueError(
|
||
"alpha_pattern is incompatible with use_vera=True. Disable "
|
||
"use_vera or remove alpha_pattern."
|
||
)
|
||
return self
|
||
|
||
|
||
class DataConfig(BaseModel):
|
||
train: str = Field(..., description="Path to training data or HF dataset name")
|
||
format: Literal[
|
||
"alpaca", "sharegpt", "chatml", "dpo", "kto", "llava", "sharegpt4v",
|
||
"plaintext", "embedding", "audio", "tool-calling", "auto",
|
||
# v0.42.0 — Data Pipeline Pro
|
||
"prm", "pre_tokenized", "input_output", "video", "multimodal",
|
||
# v0.62.0 Part A — RAFT (Retrieval-Augmented Fine-Tuning)
|
||
"raft",
|
||
] = Field(
|
||
default="auto",
|
||
description="Data format",
|
||
)
|
||
val_split: float = Field(default=0.1, ge=0.0, le=0.5, description="Validation split ratio")
|
||
max_length: int = Field(
|
||
default=2048, ge=64, le=1048576,
|
||
description="Max sequence length in tokens",
|
||
)
|
||
image_dir: Optional[str] = Field(
|
||
default=None,
|
||
description="Base directory for resolving relative image paths in vision datasets",
|
||
)
|
||
audio_dir: Optional[str] = Field(
|
||
default=None,
|
||
description="Base directory for resolving relative audio paths in audio datasets",
|
||
)
|
||
train_on_responses_only: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Mask non-assistant tokens with IGNORE_INDEX (-100). When True, "
|
||
"only assistant content contributes to the SFT loss. Mirrors "
|
||
"LlamaFactory + Axolotl default — replaces TRL's heuristic. (v0.36.0)"
|
||
),
|
||
)
|
||
train_on_messages_with_train_field: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Per-message training mask via messages[i].train: bool. "
|
||
"Mutually exclusive with train_on_responses_only. (v0.36.0)"
|
||
),
|
||
)
|
||
chat_template: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Override the tokenizer chat template. Accepts a registered "
|
||
"name (chatml, llama3, qwen2.5, mistral, gemma3, phi4, "
|
||
"deepseek-r1) or a raw Jinja string. None = use the tokenizer's "
|
||
"shipped template (errors loudly if absent). (v0.36.0)"
|
||
),
|
||
)
|
||
raft_shuffle_seed: Optional[int] = Field(
|
||
default=None,
|
||
ge=0,
|
||
le=2_147_483_647,
|
||
description=(
|
||
"Seed for the RAFT golden/distractor document shuffle "
|
||
"(data.format='raft'). Documents are always shuffled for "
|
||
"distractor robustness; this knob fixes which reproducible "
|
||
"permutation. None = seed 0. (v0.71.10 #199)"
|
||
),
|
||
)
|
||
|
||
@field_validator("raft_shuffle_seed", mode="before")
|
||
@classmethod
|
||
def _validate_raft_shuffle_seed(cls, v):
|
||
# Bool is a subclass of int — reject before Pydantic coerces True->1
|
||
# (project bool-as-int policy).
|
||
if isinstance(v, bool):
|
||
raise ValueError("raft_shuffle_seed must not be a bool")
|
||
return v
|
||
|
||
raft_epoch_shuffle: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Re-permute RAFT golden/distractor documents EACH training epoch "
|
||
"(data.format='raft'). When False (default) the document order is "
|
||
"baked once at tokenisation time and fixed across epochs; when "
|
||
"True the trainer re-composes + re-tokenises rows per epoch with "
|
||
"an epoch salt so the model cannot memorise a fixed golden-doc "
|
||
"slot. (v0.71.17 #253)"
|
||
),
|
||
)
|
||
|
||
# --- v0.42.0 Data Pipeline Pro -----------------------------------------
|
||
video_dir: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Base directory for resolving relative video paths in video "
|
||
"datasets. Mirrors image_dir / audio_dir. (v0.42.0 Part A)"
|
||
),
|
||
)
|
||
tokenized_path: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Path to a pre-tokenized cache produced by `soup data preprocess`. "
|
||
"When set, the trainer skips the tokenize stage and reads tensors "
|
||
"directly. Mirrors LF tokenized_path / Axolotl `empty` type. "
|
||
"(v0.42.0 Part C)"
|
||
),
|
||
)
|
||
streaming: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Pass-through to HF datasets `streaming=True`. Use for datasets "
|
||
"that don't fit on disk. Pairs with `buffer_size`. (v0.42.0 Part B)"
|
||
),
|
||
)
|
||
buffer_size: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Shuffle buffer size for streaming datasets. None = HF default. "
|
||
"Bounds [1, 1_000_000]. (v0.42.0 Part B)"
|
||
),
|
||
)
|
||
shards: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Number of shards for HF dataset splits (axolotl `shards`). "
|
||
"Bounds [1, 1024]. (v0.42.0 Part B)"
|
||
),
|
||
)
|
||
interleave: Optional[Union[str, Dict]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Multi-dataset interleave strategy: 'concat' / 'under' / 'over' / "
|
||
"{strategy: 'probs', probs: [...]}. (v0.42.0 Part D)"
|
||
),
|
||
)
|
||
mask_history: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"LF mask_history — mask all but the last assistant turn during "
|
||
"loss computation. (v0.42.0 Part D)"
|
||
),
|
||
)
|
||
train_on_prompt: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"LF train_on_prompt — include the prompt tokens in the loss. "
|
||
"Inverse of train_on_responses_only. (v0.42.0 Part D)"
|
||
),
|
||
)
|
||
eval_on_each_dataset: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"LF eval_on_each_dataset — when interleaving, run eval on every "
|
||
"constituent dataset separately. (v0.42.0 Part D)"
|
||
),
|
||
)
|
||
split_thinking: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Axolotl split_thinking — separate `<think>` reasoning blocks "
|
||
"from the final answer for fine-grained masking. Qwen3-style. "
|
||
"(v0.42.0 Part D)"
|
||
),
|
||
)
|
||
image_min_pixels: Optional[int] = Field(
|
||
default=None,
|
||
description="Per-image min pixel count for vision data. (v0.42.0 Part D)",
|
||
)
|
||
image_max_pixels: Optional[int] = Field(
|
||
default=None,
|
||
description="Per-image max pixel count for vision data. (v0.42.0 Part D)",
|
||
)
|
||
image_resize_algorithm: Optional[
|
||
Literal["nearest", "bilinear", "bicubic", "lanczos"]
|
||
] = Field(
|
||
default=None,
|
||
description="Pillow resize algorithm for image preprocessing. (v0.42.0 Part D)",
|
||
)
|
||
video_fps: Optional[float] = Field(
|
||
default=None,
|
||
description=(
|
||
"Target frames-per-second for video preprocessing. (v0.42.0 Part D)"
|
||
),
|
||
)
|
||
video_maxlen: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Max number of frames per video clip. Bounds (0, 4096]. "
|
||
"(v0.42.0 Part D)"
|
||
),
|
||
)
|
||
add_new_tokens: Optional[List[str]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Add these tokens to the tokenizer vocab + resize embeddings. "
|
||
"Cap 10_000 entries; per-token <= 256 chars; no duplicates. "
|
||
"(v0.42.0 Part E)"
|
||
),
|
||
)
|
||
new_special_tokens: Optional[List[str]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Like add_new_tokens but registered as additional_special_tokens "
|
||
"so they are not split by the tokenizer. (v0.42.0 Part E)"
|
||
),
|
||
)
|
||
resize_vocab: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Resize the model's input/output embedding matrix when "
|
||
"add_new_tokens / new_special_tokens grew the vocab. "
|
||
"(v0.42.0 Part E)"
|
||
),
|
||
)
|
||
extend_conversation: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Unsloth-style conversation extension — extend the last assistant "
|
||
"turn with N more tokens for 'continue' prompts. (v0.42.0 Part E)"
|
||
),
|
||
)
|
||
skip_prepare_dataset: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Axolotl skip_prepare_dataset — escape hatch when the input is "
|
||
"already in the trainer's expected schema. (v0.42.0 Part E)"
|
||
),
|
||
)
|
||
remove_unused_columns: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"HF Trainer remove_unused_columns. Set False when feeding "
|
||
"extra cols to a custom collator. (v0.42.0 Part E)"
|
||
),
|
||
)
|
||
prompt_strategy: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Axolotl-style 'module.path:function_name' Python transform. "
|
||
"Schema-only in v0.42.0 — runtime invocation lands in v0.42.1. "
|
||
"(v0.42.0 Part E)"
|
||
),
|
||
)
|
||
# ---- v0.61.0 Part A — Unlearning data sources --------------------------
|
||
forget_set: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Path or HF dataset name for the forget set (rows to unlearn). "
|
||
"Required when task='unlearn'. Null-byte rejected, capped at "
|
||
"4096 chars. Containment is deferred to the trainer-side loader "
|
||
"so HF dataset IDs (e.g. ``locuslab/TOFU``) still pass schema. "
|
||
"(v0.61.0 Part A)"
|
||
),
|
||
)
|
||
retain_set: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Path or HF dataset name for the retain set (rows whose "
|
||
"performance must be preserved). Optional but recommended — "
|
||
"NPO/SimNPO/RMU all degrade without one. Same validation as "
|
||
"forget_set. (v0.61.0 Part A)"
|
||
),
|
||
)
|
||
|
||
@field_validator("forget_set", "retain_set")
|
||
@classmethod
|
||
def _validate_unlearn_dataset_path(cls, value: Optional[str]) -> Optional[str]:
|
||
"""v0.61.0 Part A — shape-only validation for forget/retain refs.
|
||
|
||
Accepts None, an HF dataset id (e.g. ``locuslab/TOFU``), or a
|
||
local relative path. Null-byte rejected, oversize rejected.
|
||
Containment check is deliberately deferred to the trainer-side
|
||
loader so legitimate HF dataset IDs (which look like file paths
|
||
with a slash) still pass schema-load — mirrors v0.40.5
|
||
``reward_model`` policy.
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, str):
|
||
raise ValueError("forget_set / retain_set must be a string")
|
||
if not value:
|
||
return None
|
||
if "\x00" in value:
|
||
raise ValueError(
|
||
"forget_set / retain_set must not contain null bytes"
|
||
)
|
||
if len(value) > 4096:
|
||
raise ValueError(
|
||
"forget_set / retain_set must be <= 4096 chars"
|
||
)
|
||
return value
|
||
|
||
@field_validator("video_dir", "tokenized_path")
|
||
@classmethod
|
||
def _validate_v042_optional_path(cls, value: Optional[str]) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, str):
|
||
raise ValueError("path must be a string")
|
||
if not value:
|
||
return None
|
||
if "\x00" in value:
|
||
raise ValueError("path must not contain null bytes")
|
||
if len(value) > 4096:
|
||
raise ValueError("path must be <= 4096 chars")
|
||
# Schema-level containment via shared `is_under_cwd` (os.path.realpath
|
||
# + commonpath). Rejects arbitrary system paths at config load so a
|
||
# crafted soup.yaml fails fast instead of at first filesystem read.
|
||
from soup_cli.utils.paths import is_under_cwd
|
||
|
||
if not is_under_cwd(value):
|
||
raise ValueError(
|
||
"path must stay under the current working directory "
|
||
"(absolute paths outside cwd are rejected at config load)."
|
||
)
|
||
return value
|
||
|
||
@field_validator("buffer_size")
|
||
@classmethod
|
||
def _validate_buffer_size_v042(cls, value: Optional[int]) -> Optional[int]:
|
||
from soup_cli.utils.data_pipeline import validate_buffer_size
|
||
|
||
return validate_buffer_size(value)
|
||
|
||
@field_validator("shards")
|
||
@classmethod
|
||
def _validate_shards_v042(cls, value: Optional[int]) -> Optional[int]:
|
||
from soup_cli.utils.data_pipeline import validate_shards
|
||
|
||
return validate_shards(value)
|
||
|
||
@field_validator("image_min_pixels", "image_max_pixels")
|
||
@classmethod
|
||
def _validate_image_pixels_v042(cls, value, info):
|
||
from soup_cli.utils.data_pipeline import validate_image_pixels
|
||
|
||
return validate_image_pixels(info.field_name, value)
|
||
|
||
@field_validator("video_fps")
|
||
@classmethod
|
||
def _validate_video_fps_v042(cls, value: Optional[float]) -> Optional[float]:
|
||
from soup_cli.utils.data_pipeline import validate_video_fps
|
||
|
||
return validate_video_fps(value)
|
||
|
||
@field_validator("video_maxlen")
|
||
@classmethod
|
||
def _validate_video_maxlen_v042(cls, value: Optional[int]) -> Optional[int]:
|
||
from soup_cli.utils.data_pipeline import validate_video_maxlen
|
||
|
||
return validate_video_maxlen(value)
|
||
|
||
@field_validator("add_new_tokens", "new_special_tokens")
|
||
@classmethod
|
||
def _validate_new_tokens_v042(
|
||
cls, value: Optional[List[str]]
|
||
) -> Optional[List[str]]:
|
||
from soup_cli.utils.data_pipeline import validate_new_tokens
|
||
|
||
return validate_new_tokens(value)
|
||
|
||
@field_validator("prompt_strategy")
|
||
@classmethod
|
||
def _validate_prompt_strategy_v042(cls, value: Optional[str]) -> Optional[str]:
|
||
from soup_cli.utils.data_pipeline import validate_prompt_strategy
|
||
|
||
return validate_prompt_strategy(value)
|
||
|
||
@field_validator("interleave")
|
||
@classmethod
|
||
def _validate_interleave_v042(cls, value):
|
||
# Shape validation only — full ``parse_interleave`` requires
|
||
# ``num_datasets`` which the trainer supplies at runtime. We accept
|
||
# None / str / dict here and reject obvious type errors so a YAML
|
||
# like ``data.interleave: 99`` fails loudly at config load.
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, str):
|
||
from soup_cli.utils.data_pipeline import INTERLEAVE_STRATEGIES
|
||
|
||
if value not in INTERLEAVE_STRATEGIES:
|
||
raise ValueError(
|
||
f"interleave must be one of {sorted(INTERLEAVE_STRATEGIES)} "
|
||
f"or a dict — got {value!r}"
|
||
)
|
||
if value == "probs":
|
||
# Probs requires the dict form so the per-dataset weights are
|
||
# supplied — bare "probs" is meaningless.
|
||
raise ValueError(
|
||
"interleave='probs' requires a 'probs' list — use "
|
||
"{strategy: probs, probs: [...]} dict form."
|
||
)
|
||
return value
|
||
if isinstance(value, dict):
|
||
if "strategy" not in value:
|
||
raise ValueError(
|
||
"interleave dict form must include 'strategy' key"
|
||
)
|
||
return value
|
||
raise ValueError(
|
||
f"interleave must be None, a string, or a dict (got "
|
||
f"{type(value).__name__})"
|
||
)
|
||
|
||
@field_validator("chat_template")
|
||
@classmethod
|
||
def _validate_chat_template(cls, value: Optional[str]) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, str):
|
||
raise ValueError("chat_template must be a string")
|
||
if not value:
|
||
return None
|
||
if "\x00" in value:
|
||
raise ValueError("chat_template must not contain null bytes")
|
||
if len(value) > 65536:
|
||
raise ValueError("chat_template must be <= 64KB")
|
||
# Block Jinja directives that touch the filesystem or load arbitrary
|
||
# modules. Only control-flow + variable interpolation are allowed
|
||
# for raw chat-template strings (v0.36.0 security review fix).
|
||
lower = value.lower()
|
||
for tag in ("{%- include", "{% include", "{%- import", "{% import",
|
||
"{%- from", "{% from", "{%- macro", "{% macro",
|
||
"{%- extends", "{% extends"):
|
||
if tag in lower:
|
||
directive = tag.split(None, 1)[-1]
|
||
raise ValueError(
|
||
f"chat_template may not use Jinja '{directive}' directive — "
|
||
f"only control-flow and variable interpolation are allowed."
|
||
)
|
||
return value
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_loss_mask_exclusivity(self) -> "DataConfig":
|
||
if self.train_on_responses_only and self.train_on_messages_with_train_field:
|
||
raise ValueError(
|
||
"train_on_responses_only and train_on_messages_with_train_field "
|
||
"are mutually exclusive. Disable one. The per-message 'train' "
|
||
"field is opt-in for fine-grained per-message control."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v042_train_on_prompt(self) -> "DataConfig":
|
||
# train_on_prompt is the inverse semantics of train_on_responses_only —
|
||
# both True is contradictory. Match v0.36.0 loss-mask exclusivity policy.
|
||
if self.train_on_prompt and self.train_on_responses_only:
|
||
raise ValueError(
|
||
"train_on_prompt and train_on_responses_only are mutually "
|
||
"exclusive — train_on_prompt opts INTO prompt-token loss, "
|
||
"train_on_responses_only opts OUT. Pick one."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v042_image_pixel_range(self) -> "DataConfig":
|
||
if (
|
||
self.image_min_pixels is not None
|
||
and self.image_max_pixels is not None
|
||
and self.image_min_pixels > self.image_max_pixels
|
||
):
|
||
raise ValueError(
|
||
"image_min_pixels must be <= image_max_pixels"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v042_streaming_buffer(self) -> "DataConfig":
|
||
# buffer_size only meaningful when streaming=True — surface the
|
||
# mismatch loudly (mirrors v0.32.0 spike-recovery / loss-watchdog
|
||
# cross-validator policy).
|
||
if self.buffer_size is not None and not self.streaming:
|
||
raise ValueError(
|
||
"buffer_size requires streaming=True (HF datasets only "
|
||
"supports a shuffle buffer in streaming mode)."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v042_video_fields(self) -> "DataConfig":
|
||
# Video-only fields must not be set when format != 'video' to avoid
|
||
# silent no-ops (Axolotl-mode footgun this validator prevents).
|
||
video_fields = (
|
||
("video_fps", self.video_fps),
|
||
("video_maxlen", self.video_maxlen),
|
||
("video_dir", self.video_dir),
|
||
)
|
||
any_set = any(v is not None for _, v in video_fields)
|
||
if any_set and self.format not in ("video", "multimodal", "auto"):
|
||
names = [n for n, v in video_fields if v is not None]
|
||
raise ValueError(
|
||
f"video-related fields {names} require format in "
|
||
"{video, multimodal, auto} (got "
|
||
f"{self.format!r})."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v042_resize_vocab_requires_tokens(self) -> "DataConfig":
|
||
if self.resize_vocab and not (
|
||
self.add_new_tokens or self.new_special_tokens
|
||
):
|
||
raise ValueError(
|
||
"resize_vocab=True requires add_new_tokens or "
|
||
"new_special_tokens to be non-empty — otherwise the resize is "
|
||
"a no-op."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v042_pre_tokenized_path(self) -> "DataConfig":
|
||
# tokenized_path is meaningful regardless of format (Axolotl `empty`
|
||
# type expects the cache to be the source of truth). But the
|
||
# pre_tokenized format implies the path must be set.
|
||
if self.format == "pre_tokenized" and not self.tokenized_path:
|
||
raise ValueError(
|
||
"format='pre_tokenized' requires data.tokenized_path to point "
|
||
"at a cache directory produced by `soup data preprocess`."
|
||
)
|
||
return self
|
||
|
||
|
||
class AdviseConfig(BaseModel):
|
||
"""Pre-flight decision config (v0.54.0 — schema-only).
|
||
|
||
Surfaces the `soup advise` knobs through the central config schema so a
|
||
`soup.yaml` can carry persistent advise settings (e.g. a frozen goal
|
||
string + history-log path override). Live consumption is owned by
|
||
``soup_cli/commands/advise.py``; this field is informational on
|
||
``SoupConfig`` only.
|
||
"""
|
||
|
||
goal: Optional[str] = Field(
|
||
default=None,
|
||
max_length=4096,
|
||
description=(
|
||
"Default goal string for `soup advise`. Sharpens task "
|
||
"classification when set."
|
||
),
|
||
)
|
||
probe: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Run the 10-minute ROI probe by default when `soup advise` is "
|
||
"invoked through this config. Heuristic stubs in v0.54.0."
|
||
),
|
||
)
|
||
record: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Append every verdict from this config to "
|
||
"~/.soup/advise_history.jsonl with accepted=True."
|
||
),
|
||
)
|
||
|
||
@field_validator("goal")
|
||
@classmethod
|
||
def _goal_no_null_byte(cls, value: Optional[str]) -> Optional[str]:
|
||
if value is None:
|
||
return value
|
||
if not isinstance(value, str):
|
||
raise TypeError("advise.goal must be a string")
|
||
if "\x00" in value:
|
||
raise ValueError("advise.goal must not contain null bytes")
|
||
return value
|
||
|
||
|
||
class EvalGateConfig(BaseModel):
|
||
"""Eval-Gated Training config (v0.26.0 Part B).
|
||
|
||
Runs a declarative eval suite at epoch boundaries and halts training
|
||
if any task regresses below ``regression_threshold`` vs the baseline.
|
||
"""
|
||
|
||
enabled: bool = Field(
|
||
default=False,
|
||
description="Turn the eval gate on",
|
||
)
|
||
suite: Optional[str] = Field(
|
||
default=None,
|
||
description="Path to eval-suite YAML (evals/gate.yaml)",
|
||
)
|
||
every_n_epochs: int = Field(
|
||
default=1, ge=1, le=100,
|
||
description="Run gate every N epochs (1-100)",
|
||
)
|
||
regression_threshold: float = Field(
|
||
default=0.05, ge=0.0, le=1.0,
|
||
description="Max absolute drop vs baseline before regression fires",
|
||
)
|
||
baseline: Optional[str] = Field(
|
||
default=None,
|
||
description="registry://<id> | 'previous' | file path - scores to compare against",
|
||
)
|
||
on_regression: Literal["stop", "warn", "continue"] = Field(
|
||
default="stop",
|
||
description="Action on regression: stop training | warn only | continue",
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def _require_suite_when_enabled(self) -> "EvalGateConfig":
|
||
if self.enabled and not self.suite:
|
||
raise ValueError(
|
||
"eval_gate.suite is required when eval_gate.enabled=true"
|
||
)
|
||
return self
|
||
|
||
|
||
class TrainingConfig(BaseModel):
|
||
epochs: int = Field(default=3, ge=1, description="Number of training epochs")
|
||
lr: float = Field(default=2e-5, gt=0, description="Learning rate")
|
||
batch_size: Union[int, Literal["auto"]] = Field(
|
||
default="auto",
|
||
description="Batch size. 'auto' = find max that fits in memory.",
|
||
)
|
||
auto_batch_size_strategy: Literal["auto", "static", "probe"] = Field(
|
||
default="auto",
|
||
description=(
|
||
"How to pick the auto batch size: 'static' (fast formula), "
|
||
"'probe' (real OOM try/halve loop), 'auto' (probe on CUDA, "
|
||
"static on CPU). Default 'auto' (v0.36.0)."
|
||
),
|
||
)
|
||
gradient_accumulation_steps: int = Field(default=4, ge=1)
|
||
warmup_ratio: float = Field(default=0.03, ge=0.0, le=0.5)
|
||
weight_decay: float = Field(default=0.01, ge=0.0)
|
||
max_grad_norm: float = Field(default=1.0, gt=0)
|
||
lora: LoraConfig = Field(default_factory=LoraConfig)
|
||
quantization: Literal[
|
||
"4bit",
|
||
"8bit",
|
||
"none",
|
||
"gptq",
|
||
"awq",
|
||
"hqq:1bit",
|
||
"hqq:2bit",
|
||
"hqq:3bit",
|
||
"hqq:4bit",
|
||
"hqq:5bit",
|
||
"hqq:6bit",
|
||
"hqq:8bit",
|
||
"aqlm",
|
||
"eetq",
|
||
"mxfp4",
|
||
"fp8",
|
||
# v0.52.0 Part D — BitNet 1.58-bit (axolotl + onebitllms).
|
||
"bitnet_1.58",
|
||
] = Field(
|
||
default="4bit",
|
||
description=(
|
||
"Quantization (v0.38.0 — Quant Menu): "
|
||
"4bit (BNB QLoRA), 8bit (BNB), none, "
|
||
"gptq / awq (load pre-quantized checkpoint, train LoRA on top), "
|
||
"hqq:Nbit (HQQ 1-8 bit, N in {1..6, 8}), "
|
||
"aqlm (extreme 2-bit), eetq (8-bit fast), "
|
||
"mxfp4 (BNB 4-bit MXFP4 quant_type), "
|
||
"fp8 (load FP8 checkpoint with dequantize-on-load), "
|
||
"bitnet_1.58 (BitNet ternary, v0.52.0 schema-only)."
|
||
),
|
||
)
|
||
gptq_disable_exllama: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"v0.38.0 — disable exllama backend for GPTQ. PEFT requires triton "
|
||
"backend; exllama silently breaks adapter training."
|
||
),
|
||
)
|
||
bnb_4bit_quant_storage: Optional[
|
||
Literal["uint8", "float16", "bfloat16", "float32"]
|
||
] = Field(
|
||
default=None,
|
||
description=(
|
||
"v0.38.0 Part G — BNB 4-bit storage dtype. Required for FSDP+QLoRA "
|
||
"(set to 'bfloat16' or 'float16' to match compute dtype). "
|
||
"When None, BNB picks 'uint8' (legacy default)."
|
||
),
|
||
)
|
||
quantization_aware: Union[bool, Literal["fp8"]] = Field(
|
||
default=False,
|
||
description=(
|
||
"Quantization-Aware Training. False=off, True=int8 QAT (torchao), "
|
||
"'fp8'=FP8 training on H100/B100 (v0.28.0)."
|
||
),
|
||
)
|
||
fp8_recipe: Literal["tensorwise", "rowwise", "rowwise_with_gw_hp"] = Field(
|
||
default="tensorwise",
|
||
description=(
|
||
"FP8 scaling recipe (only used when quantization_aware='fp8'). "
|
||
"'tensorwise' (fastest, default), 'rowwise' (more accurate, CUTLASS rowwise), "
|
||
"'rowwise_with_gw_hp' (most accurate, grad_weight in high precision). (v0.28.1)."
|
||
),
|
||
)
|
||
optimizer: str = Field(
|
||
default="adamw_torch",
|
||
description=(
|
||
"Optimizer name. v0.41.0 expands the allowlist to cover BAdam, "
|
||
"APOLLO, Adam-mini, lomo/adalomo, grokadamw, schedule_free, "
|
||
"muon/dion/came_pytorch, and TorchAO ao_adamw_{fp8,4bit,8bit}. "
|
||
"See soup_cli.utils.optimizer_zoo.SUPPORTED_OPTIMIZERS for the "
|
||
"full list."
|
||
),
|
||
)
|
||
# v0.41.0 Part B — per-module-pattern LR override.
|
||
lr_groups: Optional[List[Dict[str, Union[str, float]]]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Per-module LR override. List of {pattern, lr} entries (or a "
|
||
"{pattern: lr} dict). First match wins; remaining params fall "
|
||
"through to the base lr. Capped at 32 entries. (v0.41.0)"
|
||
),
|
||
)
|
||
# v0.41.0 Part C — LLaMA Pro block expansion.
|
||
expand_layers: Optional[int] = Field(
|
||
default=None, ge=1, le=64,
|
||
description=(
|
||
"LLaMA Pro: append N zero-init transformer blocks and freeze "
|
||
"the original ones. Schema lands in v0.41.0 — full live wiring "
|
||
"deferred to v0.41.1."
|
||
),
|
||
)
|
||
freeze_trainable_layers: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"LLaMA Pro: signed int. Positive = train only top-N decoder "
|
||
"layers; negative = train only bottom-N. Magnitude capped at "
|
||
"1000. (v0.41.0)"
|
||
),
|
||
)
|
||
# v0.41.0 Part C schema / v0.71.12 #84 live — Mixture-of-Depths routing.
|
||
use_mod: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable Mixture-of-Depths selective-token routing (arXiv:2404.02258). "
|
||
"Live in v0.71.12 #84 for SFT + Pretrain on Llama / Qwen / Mistral; "
|
||
"each decoder layer gets a router that passes only the top-k tokens "
|
||
"(k = floor(seq_len * mod_capacity_factor)) through the block."
|
||
),
|
||
)
|
||
mod_capacity_factor: float = Field(
|
||
default=0.125, gt=0.0, le=1.0,
|
||
description=(
|
||
"Fraction of tokens routed through each block when use_mod=True. "
|
||
"Bounded (0, 1]; default 0.125 (per the MoD paper). (v0.71.12 #84)"
|
||
),
|
||
)
|
||
# v0.41.0 Part C — Friendly aliases for `quantization` (LF / Axolotl users).
|
||
load_in_8bit: Optional[bool] = Field(
|
||
default=None,
|
||
description=(
|
||
"Friendly alias for quantization='8bit' / 'none'. When True, "
|
||
"rewrites quantization to '8bit' if currently 'none'/'4bit'. "
|
||
"Conflicts with load_in_16bit. (v0.41.0)"
|
||
),
|
||
)
|
||
load_in_16bit: Optional[bool] = Field(
|
||
default=None,
|
||
description=(
|
||
"Friendly alias: when True, sets quantization='none' (full bf16/"
|
||
"fp16 LoRA). Conflicts with load_in_8bit. (v0.41.0)"
|
||
),
|
||
)
|
||
scheduler: str = Field(default="cosine", description="LR scheduler type")
|
||
save_steps: int = Field(default=100, description="Save checkpoint every N steps")
|
||
logging_steps: int = Field(default=10, description="Log metrics every N steps")
|
||
# DPO-specific
|
||
dpo_beta: float = Field(
|
||
default=0.1, gt=0, description="DPO beta — KL penalty coefficient"
|
||
)
|
||
# KTO-specific
|
||
kto_beta: float = Field(
|
||
default=0.1, gt=0, description="KTO beta — KL penalty coefficient"
|
||
)
|
||
# ORPO-specific
|
||
orpo_beta: float = Field(
|
||
default=0.1, gt=0, description="ORPO beta — odds ratio weight"
|
||
)
|
||
# SimPO-specific
|
||
simpo_gamma: float = Field(
|
||
default=0.5, ge=0, description="SimPO gamma — reward margin term"
|
||
)
|
||
cpo_alpha: float = Field(
|
||
default=1.0, gt=0, description="CPO/SimPO alpha — NLL loss weight"
|
||
)
|
||
# IPO-specific (uses DPO trainer with loss_type='ipo')
|
||
ipo_tau: float = Field(
|
||
default=0.1, gt=0, description="IPO tau — regularization strength"
|
||
)
|
||
# BCO-specific (Binary Classifier Optimization, v0.40.0 Part A)
|
||
bco_beta: float = Field(
|
||
default=0.1, gt=0, description="BCO beta — KL penalty coefficient"
|
||
)
|
||
# Unified preference loss dispatcher (v0.40.0 Part B).
|
||
# Set when task='preference'. Legacy task strings ('dpo', 'simpo', ...)
|
||
# remain first-class and are unaffected.
|
||
preference_loss: Optional[Literal["dpo", "simpo", "orpo", "ipo", "bco"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Preference loss for task='preference'. One of: dpo, simpo, orpo, "
|
||
"ipo, bco. Mutually exclusive with task in {dpo, simpo, orpo, ipo, bco}."
|
||
),
|
||
)
|
||
# KL-controlled DPO variants (v0.40.0 Part C).
|
||
dpo_beta_schedule: Optional[Literal["linear", "cosine", "exponential"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Anneal DPO β over training. None = constant β (default). Requires "
|
||
"dpo_beta_end. DPO-family tasks only (dpo, ipo, preference+dpo)."
|
||
),
|
||
)
|
||
dpo_beta_end: Optional[float] = Field(
|
||
default=None,
|
||
gt=0,
|
||
description=(
|
||
"Target β at the end of training when dpo_beta_schedule is set. "
|
||
"Must be > 0. The starting β is dpo_beta."
|
||
),
|
||
)
|
||
dpo_ref_regen_epochs: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=1000,
|
||
description=(
|
||
"Replace the frozen ref model with the current student every N "
|
||
"epochs. None = never regen (default). DPO-family tasks only."
|
||
),
|
||
)
|
||
# Multi-objective preference loss (v0.40.0 Part D).
|
||
preference_loss_weights: Optional[dict[str, float]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Weighted blend of preference losses, e.g. {'dpo': 0.7, 'bco': 0.3}. "
|
||
"Each weight ∈ (0, 1]; weights must sum to 1.0 (±1e-6). All keys "
|
||
"must be members of {dpo, simpo, orpo, ipo, bco}. Requires "
|
||
"task='preference'; mutually exclusive with preference_loss (the "
|
||
"scalar form). Capped at 5 components (the supported set)."
|
||
),
|
||
)
|
||
# GRPO-specific
|
||
grpo_beta: float = Field(
|
||
default=0.1, gt=0, description="GRPO beta — KL penalty coefficient"
|
||
)
|
||
num_generations: int = Field(
|
||
default=4, ge=2, description="Number of generations per prompt for GRPO"
|
||
)
|
||
reward_fn: Optional[str] = Field(
|
||
default="accuracy",
|
||
description=(
|
||
"Reward function: 'accuracy', 'format', 'verifiable', "
|
||
"or path to custom .py file"
|
||
),
|
||
)
|
||
# RLVR — verifiable reward domain (Part C of v0.25.0)
|
||
verifiable_domain: Optional[Literal["math", "code", "json_schema"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"RLVR verifiable reward domain: math | code | json_schema. "
|
||
"Required when reward_fn='verifiable'."
|
||
),
|
||
)
|
||
# v0.50.0 Part A — GRPO objective variants (unsloth + axolotl parity).
|
||
# Schema-only in v0.50.0; live loss kernels wired in v0.50.1.
|
||
grpo_variant: Optional[Literal[
|
||
"standard", "gspo", "dapo", "dr_grpo", "bnpo", "two_sided", "rft"
|
||
]] = Field(
|
||
default=None,
|
||
description=(
|
||
"GRPO objective variant: standard | gspo | dapo | dr_grpo | "
|
||
"bnpo | two_sided | rft. Defaults to None (legacy GRPO). "
|
||
"Requires task='grpo'. Live wiring for v0.50.0 additions is "
|
||
"deferred to v0.50.1 (schema gate only)."
|
||
),
|
||
)
|
||
grpo_delta: Optional[float] = Field(
|
||
default=None,
|
||
gt=0.0,
|
||
le=1.0,
|
||
description=(
|
||
"Symmetric clipping radius for grpo_variant='two_sided'. "
|
||
"Required when grpo_variant='two_sided'; rejected otherwise."
|
||
),
|
||
)
|
||
grpo_fp16: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Force FP16 mixed precision for GRPO/RL. Soup currently has "
|
||
"FP8 RL support; this flag is the explicit FP16 opt-in (unsloth "
|
||
"parity). v0.50.0: schema-only; live mixed-precision routing "
|
||
"deferred to v0.50.1."
|
||
),
|
||
)
|
||
# v0.50.0 Part B — Long-context + memory-efficient RL
|
||
long_context_grpo: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable long-context GRPO (unsloth: 380K B200 / 110K H100). "
|
||
"Wires Tiled MLP from v0.56.0 Part A; schema-only in v0.50.0. "
|
||
"Requires task='grpo' on a non-mlx backend and "
|
||
"use_ring_attention=False (both rewrite attention)."
|
||
),
|
||
)
|
||
vllm_sleep_mode: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable vLLM sleep/standby between rollouts (memory savings "
|
||
"during the optimisation step). Requires backend in "
|
||
"{transformers, unsloth}. Schema-only in v0.50.0; live wiring "
|
||
"in v0.50.1."
|
||
),
|
||
)
|
||
# v0.50.0 Part C — Multi-turn agent rollout backend (live v0.71.21 #125)
|
||
rollout_backend: Optional[Literal[
|
||
"art", "ruler", "nemo_gym", "openenv"
|
||
]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Multi-turn agent rollout backend (unsloth / axolotl parity): "
|
||
"art (OpenPipe ART) / ruler / nemo_gym / openenv. "
|
||
"Requires task='grpo'. openenv runs live (v0.71.21 #125) via "
|
||
"training.rollout_func; art/ruler/nemo_gym are lazy-import "
|
||
"gated."
|
||
),
|
||
)
|
||
# v0.71.21 #125 — user-supplied OpenEnv rollout callable.
|
||
rollout_func: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"OpenEnv rollout function as 'module.path:function_name' "
|
||
"(v0.71.21 #125). Requires rollout_backend='openenv'. The "
|
||
"callable receives the seed prompts list and returns rollout "
|
||
"rows ({'prompt': str|messages, 'answer'?: str}) that replace "
|
||
"the GRPO prompt dataset. Trusted-input policy: names "
|
||
"operator-controlled code (mirrors data.prompt_strategy)."
|
||
),
|
||
)
|
||
|
||
@field_validator("rollout_func", mode="before")
|
||
@classmethod
|
||
def _validate_rollout_func_field(cls, value):
|
||
"""v0.71.21 #125 — module:fn shape validation at config load."""
|
||
from soup_cli.utils.agent_rollout import validate_rollout_func
|
||
|
||
return validate_rollout_func(value)
|
||
# v0.50.0 Part D — GRPO stability / efficiency knobs (axolotl + unsloth).
|
||
# All schema-only in v0.50.0; live trainer callbacks wired in v0.50.1.
|
||
ref_model_ema_alpha: Optional[float] = Field(
|
||
default=None,
|
||
gt=0.0,
|
||
le=1.0,
|
||
description=(
|
||
"Exponential moving average coefficient for ref-model sync "
|
||
"(policy → reference). Must be in (0, 1]. None = disabled. "
|
||
"Axolotl parity."
|
||
),
|
||
)
|
||
replay_buffer_size: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=1_000_000,
|
||
description=(
|
||
"Bounded replay buffer size for GRPO rollouts. None = disabled. "
|
||
"Axolotl parity."
|
||
),
|
||
)
|
||
async_grpo_prefetch: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Overlap rollout + train via async prefetch (axolotl). "
|
||
"Requires backend in {transformers, unsloth}."
|
||
),
|
||
)
|
||
tis_threshold: Optional[float] = Field(
|
||
default=None,
|
||
gt=0.0,
|
||
le=100.0,
|
||
description=(
|
||
"Truncated importance sampling threshold (unsloth, axolotl). "
|
||
"Must be in (0, 100]. None = disabled."
|
||
),
|
||
)
|
||
mask_truncated_completions: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Mask out truncated completions when computing the policy "
|
||
"gradient (paired with tis_threshold). Unsloth + axolotl parity."
|
||
),
|
||
)
|
||
defer_rerolling: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Defer re-rolling identical prompts across optimisation steps "
|
||
"(axolotl). Saves rollouts on repeat prompts."
|
||
),
|
||
)
|
||
skip_zero_advantage: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Skip backward pass on samples whose advantage is exactly zero "
|
||
"(axolotl). Avoids wasted compute on no-signal samples."
|
||
),
|
||
)
|
||
off_policy_mask_threshold: Optional[float] = Field(
|
||
default=None,
|
||
ge=0.0,
|
||
le=1.0,
|
||
description=(
|
||
"Off-policy mask threshold for token/sequence gating (axolotl). "
|
||
"Must be in [0, 1]. None = disabled."
|
||
),
|
||
)
|
||
# v0.50.0 Part E — Vision-RL opt-in flag
|
||
vision_grpo: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable Vision RL / VLM RL — extends GRPO/PPO to vision "
|
||
"modality (Qwen2-VL / Pixtral / InternVL). Requires "
|
||
"modality='vision', task in {grpo, ppo}, backend in "
|
||
"{transformers, unsloth}. Schema-only in v0.50.0; live VLM-RL "
|
||
"rollout wiring deferred to v0.50.1."
|
||
),
|
||
)
|
||
# v0.51.0 Part E — alternative model hubs (ModelScope / Modelers)
|
||
hub: Literal["hf", "modelscope", "modelers"] = Field(
|
||
default="hf",
|
||
description=(
|
||
"Model hub for downloads + pushes. 'hf' (default), 'modelscope' "
|
||
"(China-hosted; mirrors most Llama/Qwen/etc.), 'modelers' "
|
||
"(Openmind hub). Schema-only in v0.51.0; live downloader / "
|
||
"uploader wiring deferred to v0.51.1."
|
||
),
|
||
)
|
||
|
||
# ---- v0.52.0 — Modality II (schema-only; live wiring in v0.52.1) ----
|
||
# Part A — TTS
|
||
tts_family: Optional[Literal[
|
||
"orpheus", "sesame_csm", "llasa", "spark", "oute"
|
||
]] = Field(
|
||
default=None,
|
||
description=(
|
||
"TTS model family — required when task='tts'. One of: orpheus, "
|
||
"sesame_csm, llasa, spark, oute. Schema-only in v0.52.0; live "
|
||
"trainer wrapper deferred to v0.52.1."
|
||
),
|
||
)
|
||
tts_emotion: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Optional emotion tag for emotion-conditioned families "
|
||
"(Orpheus / Oute). Allowlisted per-family. (v0.52.0)"
|
||
),
|
||
)
|
||
# Part B — classifier / reranker / cross_encoder
|
||
num_labels: Optional[int] = Field(
|
||
default=None, ge=1, le=1024,
|
||
description=(
|
||
"Number of output labels for task in (classifier, reranker, "
|
||
"cross_encoder). Required when task is one of those. (v0.52.0)"
|
||
),
|
||
)
|
||
classifier_kind: Optional[Literal["single_label", "multi_label"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Sequence-classification head kind: single_label (default for "
|
||
"task='classifier') or multi_label. (v0.52.0)"
|
||
),
|
||
)
|
||
label_names: Optional[List[str]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Optional human-readable label names. Length must match "
|
||
"num_labels. Capped at 1024 entries. (v0.52.0)"
|
||
),
|
||
)
|
||
# MoLE per-token adapter routing (v0.67.0 schema / v0.71.12 #222 live)
|
||
mole_task_adapters: Optional[List[str]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Paths (HF ids or local dirs) to the N pre-trained task LoRA "
|
||
"adapters routed over by the MoLE gate. Required when "
|
||
"task='moe_lora_routing'; 2-64 entries, deduplicated. The base "
|
||
"model + every task adapter stay frozen — only the gate trains. "
|
||
"(v0.71.12 #222)"
|
||
),
|
||
)
|
||
mole_top_k: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Number of task adapters each token is routed to (sparse top-k "
|
||
"dispatch). Defaults to num_task_adapters (dense) when unset. "
|
||
"1 <= top_k <= len(mole_task_adapters). (v0.71.12 #222)"
|
||
),
|
||
)
|
||
mole_temperature: Optional[float] = Field(
|
||
default=None,
|
||
description=(
|
||
"Softmax temperature for the MoLE router (default 1.0). "
|
||
"(1e-6, 100.0]. (v0.71.12 #222)"
|
||
),
|
||
)
|
||
# Part C — knowledge distillation
|
||
teacher_model: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Teacher model HF id or local path — required when task='distill'. "
|
||
"Null-byte rejected, capped at 512 chars. Schema-only in v0.52.0; "
|
||
"live distill trainer deferred to v0.52.1."
|
||
),
|
||
)
|
||
distill_divergence: Optional[Literal[
|
||
"forward_kl", "reverse_kl", "js"
|
||
]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Divergence used for distillation loss. 'kl' is an alias for "
|
||
"'forward_kl' (canonical form). (v0.52.0)"
|
||
),
|
||
)
|
||
distill_temperature: Optional[float] = Field(
|
||
default=None,
|
||
description=(
|
||
"Softmax temperature applied to teacher and student logits "
|
||
"before the divergence. Bounded [0.05, 100.0]. (v0.52.0)"
|
||
),
|
||
)
|
||
distill_mode: Literal["token", "sequence"] = Field(
|
||
default="token",
|
||
description=(
|
||
"Distillation mode. 'token' (default, v0.53.2) = column-aligned "
|
||
"logit KL — requires a shared tokenizer (or set uld_strategy for "
|
||
"the cross-tokenizer logit path). 'sequence' (v0.71.12) = "
|
||
"sequence-level KD: the teacher GENERATES a completion per prompt "
|
||
"and the student does plain CE on the re-tokenised output, which "
|
||
"works across ANY tokenizer pair (e.g. Llama-3 student / Qwen-2 "
|
||
"teacher)."
|
||
),
|
||
)
|
||
# v0.71.12 #146 — opt-in LoRA / PEFT path for classifier-family tasks.
|
||
classifier_lora: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Wrap the sequence-classification head with LoRA (task_type="
|
||
"'SEQ_CLS') instead of full fine-tuning. Opt-in (default False "
|
||
"preserves the v0.53.2 full-finetune behaviour). Reuses the "
|
||
"training.lora block (r / alpha / dropout / target_modules). "
|
||
"Only honored for task in (classifier, reranker, cross_encoder). "
|
||
"(v0.71.12 #146)"
|
||
),
|
||
)
|
||
# Part E — EBFT + GDPO
|
||
ebft_variant: Optional[Literal["structured", "strided"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Energy-Based FT variant. SFT-task-only; live loss kernel "
|
||
"deferred to v0.52.1. (v0.52.0)"
|
||
),
|
||
)
|
||
ebft_temperature: Optional[float] = Field(
|
||
default=None,
|
||
description=(
|
||
"Sampling temperature for EBFT energy proxy. Bounded "
|
||
"[1e-4, 100.0]. (v0.52.0)"
|
||
),
|
||
)
|
||
gdpo_variant: Optional[Literal[
|
||
"standard", "length_normalized", "margin"
|
||
]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Generalized DPO variant. DPO-family-task-only; live loss kernel "
|
||
"deferred to v0.52.1. (v0.52.0)"
|
||
),
|
||
)
|
||
# Part F — MoE expert quantization + router-only training
|
||
moe_expert_quant: Optional[Literal["nf4", "int8_rowwise"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Per-expert quantization for fused-MoE Linear blocks. "
|
||
"Requires moe_lora=true. (v0.52.0)"
|
||
),
|
||
)
|
||
train_router_only: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Freeze every expert + train only the gating router (unsloth "
|
||
"MoE recipe). Requires moe_lora=true. (v0.52.0)"
|
||
),
|
||
)
|
||
# Part G — gpt-oss reasoning effort + EOT control
|
||
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"gpt-oss train-time reasoning effort level. Routes through a "
|
||
"prompt prefix at training time; live formatter wiring deferred "
|
||
"to v0.52.1. (v0.52.0)"
|
||
),
|
||
)
|
||
train_on_eot: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Include explicit EOT / EOS control tokens in the SFT loss "
|
||
"(axolotl ``train_on_eot``). Default False matches HF Trainer "
|
||
"convention. (v0.52.0)"
|
||
),
|
||
)
|
||
# ---- v0.53.0 Quant Menu II — UD GGUFs + KV cache + NVFP4 ---------------
|
||
# Part C — KV cache types (serve-side hint, captured here for round-trip).
|
||
kv_cache_type: Optional[Literal["q8_0", "bf16", "f16", "fp8"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"KV-cache element type for inference (q8_0 / bf16 / f16 / fp8). "
|
||
"Schema-only in v0.53.0; live wiring deferred to v0.53.1."
|
||
),
|
||
)
|
||
# Part D — Train-time advanced precision.
|
||
fp8_attention: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Extend the v0.28.0 FP8 menu to FP8 attention "
|
||
"(axolotl-parity flag). Requires quantization_aware='fp8'. "
|
||
"Schema-only in v0.53.0; live wiring deferred to v0.53.1."
|
||
),
|
||
)
|
||
nvfp4: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Blackwell-only NVFP4 training (unsloth + axolotl). "
|
||
"Schema-only in v0.53.0; live wiring deferred to v0.53.1."
|
||
),
|
||
)
|
||
unsloth_bnb_4bit: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Promote Unsloth Dynamic 4-bit from 'inferable' to a native flag. "
|
||
"Requires backend='unsloth' and quantization='4bit'. (v0.53.0)"
|
||
),
|
||
)
|
||
# Part E — LF / Axolotl parity.
|
||
bnb_4bit_use_double_quant: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Apply BNB 4-bit double-quantization (LF / Axolotl parity). "
|
||
"Only meaningful when quantization='4bit'. (v0.53.0)"
|
||
),
|
||
)
|
||
llm_int8: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Explicit 8-bit LLM.int8 alias for quantization='8bit'. "
|
||
"When True, requires quantization='8bit'. (v0.53.0)"
|
||
),
|
||
)
|
||
quantize_ref_model: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Apply the same Quant Menu config to the reference model "
|
||
"(DPO/IPO/SimPO/ORPO/BCO ref model) — extends v0.40.5. (v0.53.0)"
|
||
),
|
||
)
|
||
quantize_reward_model: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Apply the same Quant Menu config to the reward model "
|
||
"(PPO/reward_model task) — extends v0.40.5. (v0.53.0)"
|
||
),
|
||
)
|
||
|
||
# ---- v0.70.0 Part F — Echo-trap detector -----------------------------
|
||
echo_trap_enabled: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable RAGEN-style echo-trap detection during multi-turn "
|
||
"agent RL. Requires task in {'grpo', 'ppo'} on a non-mlx "
|
||
"backend. Schema-only in v0.70.0; live callback in v0.70.1."
|
||
),
|
||
)
|
||
echo_trap_threshold: float = Field(
|
||
default=0.6,
|
||
ge=0.0,
|
||
le=1.0,
|
||
description=(
|
||
"Threshold on the aggregate echo signal. Above this = TRAP. "
|
||
"Bounded [0.0, 1.0]. (v0.70.0)"
|
||
),
|
||
)
|
||
echo_trap_halt: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Auto-halt training on TRAP verdict. Requires "
|
||
"echo_trap_enabled=True. (v0.70.0)"
|
||
),
|
||
)
|
||
echo_trap_tokenizer_aware: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Use tokenizer-id n-grams for echo-trap scoring instead of "
|
||
"whitespace tokens. More sensitive to subword repetition but "
|
||
"bound to the active tokenizer vocabulary. Requires "
|
||
"echo_trap_enabled=True. (v0.70.x)"
|
||
),
|
||
)
|
||
|
||
# ---- v0.70.0 Part D — Mid-epoch RL checkpoint ------------------------
|
||
rl_checkpoint_save_every_steps: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=10_000_000,
|
||
description=(
|
||
"Save an RL-aware mid-epoch checkpoint every N steps. None "
|
||
"= use HF Trainer's per-epoch checkpoint only. Requires "
|
||
"task in {'grpo', 'ppo'}. Schema-only in v0.70.0; live "
|
||
"save_state / load_state in v0.70.1."
|
||
),
|
||
)
|
||
rl_checkpoint_keep_last: int = Field(
|
||
default=3,
|
||
ge=1,
|
||
le=100,
|
||
description=(
|
||
"Number of recent RL checkpoints to retain. Older ones are "
|
||
"pruned at write time. (v0.70.0)"
|
||
),
|
||
)
|
||
rl_checkpoint_include_optimizer: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Include AdamW / Lion optimizer state in the mid-epoch RL "
|
||
"checkpoint. (v0.70.0)"
|
||
),
|
||
)
|
||
rl_checkpoint_include_ref_model: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Include the frozen reference model state in the RL "
|
||
"checkpoint. Default False (ref model is reconstructable "
|
||
"from cfg.base). (v0.70.0)"
|
||
),
|
||
)
|
||
rl_checkpoint_include_rollout_buffer: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Include the rollout / replay buffer in the RL checkpoint "
|
||
"so resumed runs don't lose collected experience. (v0.70.0)"
|
||
),
|
||
)
|
||
|
||
# ---- v0.70.0 Part C — MiniLLM reverse-KL on-policy distillation -------
|
||
# Bundles teacher-mixed sampling + length-norm + pretrain anchor.
|
||
# Schema-only; live callback wired in v0.70.1.
|
||
minillm_enabled: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable MiniLLM-style on-policy distillation (Gu et al. 2024). "
|
||
"Requires task='distill' on a non-mlx backend. v0.70.0 "
|
||
"schema-only; live callback in v0.70.1."
|
||
),
|
||
)
|
||
minillm_teacher_mix_ratio: float = Field(
|
||
default=0.0,
|
||
ge=0.0,
|
||
le=1.0,
|
||
description=(
|
||
"Probability of sampling from the teacher distribution at "
|
||
"rollout time. 0.0 = student-only; 1.0 = teacher-only. "
|
||
"Typical range 0.2-0.5. (v0.70.0)"
|
||
),
|
||
)
|
||
minillm_length_normalize: bool = Field(
|
||
default=True,
|
||
description=(
|
||
"Length-normalise the rollout log-probability before the "
|
||
"reverse-KL term. Prevents long completions from dominating "
|
||
"the gradient. (v0.70.0)"
|
||
),
|
||
)
|
||
minillm_pretrain_anchor_weight: float = Field(
|
||
default=0.0,
|
||
ge=0.0,
|
||
le=1.0,
|
||
description=(
|
||
"Weight on the pretrain-loss anchor term (SFT on a small "
|
||
"pretrain corpus). Prevents drift away from coherent "
|
||
"language. Requires minillm_pretrain_anchor_path when > 0. "
|
||
"(v0.70.0)"
|
||
),
|
||
)
|
||
minillm_pretrain_anchor_path: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Path to the pretrain JSONL used by the anchor term. "
|
||
"Required when minillm_pretrain_anchor_weight > 0. "
|
||
"Null-byte rejected; capped at 4096 chars. (v0.70.0)"
|
||
),
|
||
)
|
||
minillm_on_policy: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Use the TRUE on-policy MiniLLM teacher-mixed rollout (Gu et al. "
|
||
"2024 §3.1): sample a fresh autoregressive rollout per step "
|
||
"(per-token teacher/student mix) then compute length-normalised "
|
||
"reverse-KL on it. Default off = the cheap offline distribution "
|
||
"blend. Requires minillm_enabled=True. (v0.71.18 #257)"
|
||
),
|
||
)
|
||
minillm_rollout_length: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=512,
|
||
description=(
|
||
"On-policy rollout length (number of generated tokens per step). "
|
||
"When None the distill trainer auto-derives min(max_length, 32) "
|
||
"for the consumer-GPU budget. The autoregressive loop re-forwards "
|
||
"the full growing prefix each step (~O(L^2) graph), so keep this "
|
||
"small. Requires minillm_on_policy=True. (v0.71.18 #257)"
|
||
),
|
||
)
|
||
|
||
# ---- v0.70.0 Part B — Cross-tokenizer ULD ----------------------------
|
||
# Universal Logit Distillation (Boizard et al. 2024). Schema-only;
|
||
# live projection module wired in v0.70.1.
|
||
uld_strategy: Optional[
|
||
Literal["wasserstein", "topk_align", "wasserstein_aligned"]
|
||
] = Field(
|
||
default=None,
|
||
description=(
|
||
"Cross-tokenizer distillation strategy: 'wasserstein' "
|
||
"(no alignment needed), 'topk_align' (requires uld_top_k), or "
|
||
"'wasserstein_aligned' (token-sequence alignment for fully "
|
||
"disjoint tokenizers, v0.71.18 #258). Requires task='distill' on "
|
||
"a non-mlx backend."
|
||
),
|
||
)
|
||
uld_top_k: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=262144,
|
||
description=(
|
||
"Top-K teacher logits to align (uld_strategy='topk_align' "
|
||
"only). Bounded [1, 262144] to cap pathological vocabs. "
|
||
"(v0.70.0)"
|
||
),
|
||
)
|
||
# ---- v0.70.0 Part A — Reward-hacking detector ------------------------
|
||
# Schema-only release; live HF Trainer callback wired in v0.70.1.
|
||
reward_hack_detector: Optional[Literal["info_rm", "rm_ensemble"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Reward-hacking detector for GRPO/PPO. 'info_rm' tracks "
|
||
"InfoRM cluster-separation across training; 'rm_ensemble' "
|
||
"tracks pairwise variance across an RM ensemble. Requires "
|
||
"task in {'grpo', 'ppo'} on a non-mlx backend. Schema-only "
|
||
"in v0.70.0; live HF Trainer callback wired in v0.70.1."
|
||
),
|
||
)
|
||
reward_hack_halt: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Auto-halt training on HACK verdict (drop_pct >= 30% in "
|
||
"cluster separation). Requires reward_hack_detector to be "
|
||
"set. (v0.70.0)"
|
||
),
|
||
)
|
||
|
||
@field_validator("reward_hack_halt", mode="before")
|
||
@classmethod
|
||
def _validate_reward_hack_halt(cls, v):
|
||
"""v0.70.0 — explicit bool guard so YAML ``yes`` / ``1`` integers
|
||
cannot silently coerce. Matches project bool-before-int policy.
|
||
"""
|
||
if v is None:
|
||
return v
|
||
if isinstance(v, bool):
|
||
return v
|
||
raise TypeError(
|
||
f"reward_hack_halt must be bool, got {type(v).__name__}"
|
||
)
|
||
|
||
@field_validator(
|
||
"echo_trap_enabled",
|
||
"echo_trap_halt",
|
||
"echo_trap_tokenizer_aware",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _validate_echo_trap_bool_fields(cls, v):
|
||
"""v0.70.0 Part F — bool guards for echo-trap toggles."""
|
||
if v is None:
|
||
return v
|
||
if isinstance(v, bool):
|
||
return v
|
||
raise TypeError(
|
||
f"v0.70.0 echo-trap flag must be bool, got {type(v).__name__}"
|
||
)
|
||
|
||
@field_validator(
|
||
"rl_checkpoint_include_optimizer",
|
||
"rl_checkpoint_include_ref_model",
|
||
"rl_checkpoint_include_rollout_buffer",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _validate_rl_checkpoint_bool_fields(cls, v):
|
||
"""v0.70.0 Part D — bool guards for RL-checkpoint toggles."""
|
||
if v is None:
|
||
return v
|
||
if isinstance(v, bool):
|
||
return v
|
||
raise TypeError(
|
||
f"v0.70.0 RL-checkpoint flag must be bool, got {type(v).__name__}"
|
||
)
|
||
|
||
@field_validator(
|
||
"minillm_enabled",
|
||
"minillm_length_normalize",
|
||
"minillm_on_policy",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _validate_minillm_bool_fields(cls, v):
|
||
"""v0.70.0 Part C — bool guards for MiniLLM toggles."""
|
||
if v is None:
|
||
return v
|
||
if isinstance(v, bool):
|
||
return v
|
||
raise TypeError(
|
||
f"v0.70.0 MiniLLM flag must be bool, got {type(v).__name__}"
|
||
)
|
||
|
||
@field_validator("minillm_pretrain_anchor_path")
|
||
@classmethod
|
||
def _validate_minillm_anchor_path(cls, v):
|
||
"""v0.70.0 Part C — shape-only path validation. Cwd containment
|
||
deferred to v0.70.1 runtime hook (matches v0.69.0 build_dag /
|
||
magpie base_model policy).
|
||
"""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.minillm import _check_path_shape
|
||
|
||
return _check_path_shape(v)
|
||
|
||
@field_validator("minillm_rollout_length", mode="before")
|
||
@classmethod
|
||
def _validate_minillm_rollout_length(cls, v):
|
||
"""v0.71.18 #257 — reject bool before Pydantic coerces True->1."""
|
||
if v is None:
|
||
return v
|
||
if isinstance(v, bool):
|
||
raise TypeError("minillm_rollout_length must not be bool")
|
||
return v
|
||
|
||
@field_validator(
|
||
"fp8_attention",
|
||
"nvfp4",
|
||
"unsloth_bnb_4bit",
|
||
"bnb_4bit_use_double_quant",
|
||
"llm_int8",
|
||
"quantize_ref_model",
|
||
"quantize_reward_model",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _validate_v053_bool_fields(cls, v):
|
||
"""v0.53.0 — explicit bool guard so YAML ``yes`` / ``1`` integers
|
||
cannot silently coerce. Matches project bool-before-int policy.
|
||
|
||
``None`` falls through to Pydantic so the field's ``default=False``
|
||
applies (review-fix — avoids silent ``None → False`` coercion that
|
||
would mask YAML typos like ``fp8_attention: ~``).
|
||
"""
|
||
if v is None:
|
||
return v
|
||
if isinstance(v, bool):
|
||
return v
|
||
raise TypeError(
|
||
f"v0.53.0 flag must be bool, got {type(v).__name__}"
|
||
)
|
||
|
||
@field_validator("kv_cache_type", mode="before")
|
||
@classmethod
|
||
def _validate_kv_cache_type(cls, v):
|
||
"""v0.53.0 Part C — bool / null-byte / oversize / case-insensitive
|
||
normalisation via the shared helper.
|
||
"""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.kv_cache import validate_kv_cache_type
|
||
|
||
return validate_kv_cache_type(v)
|
||
|
||
@field_validator("teacher_model")
|
||
@classmethod
|
||
def _validate_teacher_model(cls, v: Optional[str]) -> Optional[str]:
|
||
"""v0.52.0 Part C — null-byte rejection + 512-char cap.
|
||
|
||
Mirrors v0.40.5 ``reward_model`` field-validator policy. The bool
|
||
rejection happens inside ``validate_teacher_model`` which lives in
|
||
``utils/distill.py`` so the runtime validator and schema agree on
|
||
what's accepted.
|
||
"""
|
||
if v is None:
|
||
return v
|
||
from soup_cli.utils.distill import validate_teacher_model
|
||
|
||
return validate_teacher_model(v)
|
||
|
||
@field_validator("distill_divergence", mode="before")
|
||
@classmethod
|
||
def _normalize_distill_divergence(cls, v):
|
||
"""v0.52.0 Part C — canonicalise ``kl`` → ``forward_kl``.
|
||
|
||
Mirrors v0.51.0 ``_normalize_hub`` policy: the field validator runs
|
||
the shared ``validate_*`` helper at ``mode='before'`` so the public
|
||
schema and runtime validator agree on what's accepted.
|
||
"""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.distill import validate_divergence
|
||
|
||
return validate_divergence(v)
|
||
|
||
@field_validator("distill_temperature", mode="before")
|
||
@classmethod
|
||
def _validate_distill_temperature(cls, v):
|
||
"""v0.52.0 Part C — bool/NaN-rejected float in [0.05, 100]."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.distill import validate_distill_temperature
|
||
|
||
return validate_distill_temperature(v)
|
||
|
||
@field_validator("distill_mode", mode="before")
|
||
@classmethod
|
||
def _validate_distill_mode(cls, v):
|
||
"""v0.71.12 #145 — canonicalise + reject unknown distill modes."""
|
||
if v is None:
|
||
return "token"
|
||
from soup_cli.utils.distill import validate_distill_mode
|
||
|
||
return validate_distill_mode(v)
|
||
|
||
@field_validator("mod_capacity_factor", mode="before")
|
||
@classmethod
|
||
def _validate_mod_capacity_factor(cls, v):
|
||
"""v0.71.12 #84 — bool-before-float guard (bool subclasses int)."""
|
||
if v is None:
|
||
return 0.125
|
||
from soup_cli.utils.mod import validate_capacity_factor
|
||
|
||
return validate_capacity_factor(v)
|
||
|
||
@field_validator("ebft_temperature", mode="before")
|
||
@classmethod
|
||
def _validate_ebft_temperature(cls, v):
|
||
"""v0.52.0 Part E — bool/NaN-rejected float in [1e-4, 100]."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.ebft_gdpo import validate_ebft_temperature
|
||
|
||
return validate_ebft_temperature(v)
|
||
|
||
@field_validator("label_names")
|
||
@classmethod
|
||
def _validate_label_names(cls, v):
|
||
"""v0.52.0 Part B — dedup + per-entry validation."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.classifier import validate_label_names
|
||
|
||
return validate_label_names(v)
|
||
|
||
@field_validator("mole_task_adapters")
|
||
@classmethod
|
||
def _validate_mole_task_adapters(cls, v):
|
||
"""v0.71.12 #222 — 2-64 deduplicated non-empty path strings."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.mole_routing import validate_mole_task_adapters
|
||
|
||
return validate_mole_task_adapters(v)
|
||
|
||
@field_validator("mole_top_k", mode="before")
|
||
@classmethod
|
||
def _validate_mole_top_k(cls, v):
|
||
"""v0.71.12 #222 — bool-before-int guard (bool subclasses int)."""
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, bool):
|
||
raise ValueError("mole_top_k must be int, not bool")
|
||
if not isinstance(v, int):
|
||
raise ValueError("mole_top_k must be int")
|
||
if v < 1:
|
||
raise ValueError(f"mole_top_k must be >= 1, got {v}")
|
||
return v
|
||
|
||
@field_validator("mole_temperature", mode="before")
|
||
@classmethod
|
||
def _validate_mole_temperature(cls, v):
|
||
"""v0.71.12 #222 — finite float in (1e-6, 100.0]."""
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, bool):
|
||
raise ValueError("mole_temperature must not be bool")
|
||
if not isinstance(v, (int, float)):
|
||
raise ValueError("mole_temperature must be numeric")
|
||
import math as _math
|
||
|
||
fv = float(v)
|
||
if not _math.isfinite(fv):
|
||
raise ValueError("mole_temperature must be finite")
|
||
# Boundary matches MoleGatingConfig._check_finite_positive
|
||
# (MIN_TEMPERATURE=1e-6 inclusive): reject < 1e-6, accept == 1e-6.
|
||
if fv < 1e-6 or fv > 100.0:
|
||
raise ValueError(
|
||
f"mole_temperature must be in [1e-6, 100.0], got {fv}"
|
||
)
|
||
return fv
|
||
|
||
@field_validator("num_labels", mode="before")
|
||
@classmethod
|
||
def _validate_num_labels(cls, v):
|
||
"""v0.52.0 Part B (security review fix) — bool-before-int guard.
|
||
|
||
Pydantic v2's ``Field(ge=1, le=1024)`` accepts ``True`` because bool
|
||
subclasses int; explicit guard matches the project policy
|
||
established in v0.30.0 ``Candidate`` / v0.36.0 ``make_cache_key`` /
|
||
v0.41.0 ``expand_layers`` / v0.50.0 GRPO numeric fields.
|
||
"""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.classifier import validate_num_labels
|
||
|
||
return validate_num_labels(v)
|
||
|
||
@field_validator("reasoning_effort", mode="before")
|
||
@classmethod
|
||
def _validate_reasoning_effort(cls, v):
|
||
"""v0.52.0 Part G (security review fix) — canonicalise case +
|
||
bool/null-byte/oversize rejection via the shared helper.
|
||
|
||
Mirrors v0.51.0 ``_normalize_hub`` and v0.41.0 ``optimizer``
|
||
policy of routing through the public ``validate_*`` helper at
|
||
``mode='before'`` so the schema and runtime helper agree on what's
|
||
accepted.
|
||
"""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.reasoning_effort import validate_reasoning_effort
|
||
|
||
return validate_reasoning_effort(v)
|
||
|
||
@field_validator("tts_emotion")
|
||
@classmethod
|
||
def _validate_tts_emotion_field(cls, v: Optional[str]) -> Optional[str]:
|
||
"""v0.52.0 Part A — bool / null-byte / oversize rejection (without
|
||
family-specific allowlist; that fires in the cross-validator).
|
||
"""
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, bool):
|
||
raise ValueError("tts_emotion must not be bool")
|
||
if not isinstance(v, str):
|
||
raise ValueError("tts_emotion must be str")
|
||
if not v:
|
||
raise ValueError("tts_emotion must be non-empty")
|
||
if "\x00" in v:
|
||
raise ValueError("tts_emotion must not contain null bytes")
|
||
if len(v) > 32:
|
||
raise ValueError("tts_emotion too long (max 32 chars)")
|
||
return v
|
||
|
||
@field_validator("hub", mode="before")
|
||
@classmethod
|
||
def _normalize_hub(cls, v):
|
||
"""v0.51.0 Part E review fix — accept any case (HF / Modelscope /
|
||
MODELERS) and normalise to lowercase before the Literal check.
|
||
Mirrors the v0.41.0 ``optimizer`` / v0.50.0 ``grpo_variant`` /
|
||
``rollout_backend`` policy of running the shared ``validate_*``
|
||
helper at ``mode='before'`` so the public schema and the runtime
|
||
validator agree on what's accepted.
|
||
"""
|
||
# Lazy-import to avoid a hard dep cycle at module load.
|
||
from soup_cli.utils.hubs import validate_hub_name
|
||
if v is None:
|
||
return v
|
||
return validate_hub_name(v)
|
||
# PPO-specific
|
||
ppo_epochs: int = Field(
|
||
default=4, ge=1, description="Number of PPO optimization epochs per batch"
|
||
)
|
||
ppo_clip_ratio: float = Field(
|
||
default=0.2, gt=0, le=1.0, description="PPO clipping range for policy ratio"
|
||
)
|
||
ppo_kl_penalty: float = Field(
|
||
default=0.05, ge=0, description="KL divergence penalty coefficient for PPO"
|
||
)
|
||
reward_model: Optional[str] = Field(
|
||
default=None,
|
||
description="Path or HF ID of a trained reward model for PPO",
|
||
)
|
||
|
||
@field_validator("reward_model")
|
||
@classmethod
|
||
def _validate_reward_model(cls, v: Optional[str]) -> Optional[str]:
|
||
"""v0.40.5 (#66 review fix) — reject null bytes and cap length on
|
||
the reward_model string, matching the validation policy applied to
|
||
cfg.base elsewhere. The Quant Menu loader (build_quantization_config_for_loader)
|
||
already null-byte-rejects ref strings at training time; this is a
|
||
defence-in-depth check at config-load so a crafted soup.yaml fails
|
||
fast before any trainer is constructed.
|
||
"""
|
||
if v is None:
|
||
return v
|
||
if "\x00" in v:
|
||
raise ValueError("reward_model must not contain null bytes")
|
||
if len(v) > 512:
|
||
raise ValueError("reward_model must be <= 512 chars")
|
||
return v
|
||
# LoRA+ — different learning rates for A and B matrices
|
||
loraplus_lr_ratio: Optional[float] = Field(
|
||
default=None,
|
||
gt=0,
|
||
description="LoRA+ lr ratio: lr_B = lr × ratio. None = disabled (standard LoRA).",
|
||
)
|
||
# GaLore — memory-efficient full-parameter training
|
||
use_galore: bool = Field(
|
||
default=False,
|
||
description="Enable GaLore (Gradient Low-Rank Projection) for memory-efficient training",
|
||
)
|
||
galore_rank: int = Field(
|
||
default=128, ge=1, description="GaLore projection rank"
|
||
)
|
||
galore_update_proj_gap: int = Field(
|
||
default=200, ge=1, description="GaLore projection update interval (steps)"
|
||
)
|
||
galore_scale: float = Field(
|
||
default=0.25, gt=0, description="GaLore gradient scaling factor"
|
||
)
|
||
# MoE-specific
|
||
moe_lora: bool = Field(
|
||
default=False,
|
||
description="Enable MoE-aware LoRA (ScatterMoE) — applies LoRA to expert FFN layers",
|
||
)
|
||
moe_aux_loss_coeff: float = Field(
|
||
default=0.01,
|
||
ge=0,
|
||
description="Auxiliary load-balancing loss coefficient for MoE models",
|
||
)
|
||
# Performance — Liger Kernel (fused operations)
|
||
use_liger: bool = Field(
|
||
default=False,
|
||
description="Enable Liger Kernel fused operations (20-60% memory savings, 20-40% speedup)",
|
||
)
|
||
# Performance — FlashAttention
|
||
use_flash_attn: bool = Field(
|
||
default=False,
|
||
description="Enable FlashAttention (auto-detects v2/v3/v4 for faster attention)",
|
||
)
|
||
# Performance — Ring FlashAttention (sequence parallelism)
|
||
use_ring_attention: bool = Field(
|
||
default=False,
|
||
description="Enable Ring FlashAttention for sequence parallelism across GPUs",
|
||
)
|
||
# Long-context — RoPE scaling
|
||
rope_scaling_type: Optional[
|
||
Literal["linear", "dynamic", "yarn", "longrope", "llama3"]
|
||
] = Field(
|
||
default=None,
|
||
description=(
|
||
"RoPE scaling method for long-context: linear, dynamic, yarn, longrope, "
|
||
"llama3 (v0.49.0)."
|
||
),
|
||
)
|
||
# v0.49.0 Part A — YaRN-specific tunables (only meaningful when
|
||
# rope_scaling_type=='yarn'; cross-validator below enforces).
|
||
yarn_factor: Optional[float] = Field(
|
||
default=None,
|
||
gt=1.0,
|
||
le=1024.0,
|
||
description=(
|
||
"YaRN scaling factor (s). Optional — when omitted, the runtime falls "
|
||
"back to ``target_length / original_length`` (HF default behaviour)."
|
||
),
|
||
)
|
||
yarn_attn_factor: Optional[float] = Field(
|
||
default=None,
|
||
ge=0.0,
|
||
le=10.0,
|
||
description="YaRN attention temperature multiplier (default 1.0 in HF).",
|
||
)
|
||
yarn_beta_fast: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=1024,
|
||
description="YaRN beta_fast cutoff (HF default 32).",
|
||
)
|
||
yarn_beta_slow: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=1024,
|
||
description="YaRN beta_slow cutoff (HF default 1).",
|
||
)
|
||
# v0.49.0 Part C — LongLoRA S² shifted-sparse attention.
|
||
# Schema gate only; live forward override deferred to v0.49.1.
|
||
use_longlora: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable LongLoRA S² shifted-sparse attention (v0.49.0, schema-only). "
|
||
"Requires task=sft, backend=transformers, Llama-family base. "
|
||
"Mutually exclusive with use_ring_attention."
|
||
),
|
||
)
|
||
gradient_checkpointing: Union[
|
||
bool, Literal["selective", "medium", "full", "auto"]
|
||
] = Field(
|
||
default=False,
|
||
description=(
|
||
"Gradient checkpointing for memory savings on long sequences. "
|
||
"False/True (legacy bool) or tier: 'selective' (attention only), "
|
||
"'medium' (every other block), 'full' (all blocks), "
|
||
"'auto' (picks based on available VRAM). (v0.28.0)."
|
||
),
|
||
)
|
||
# v0.28.0 — Cut Cross-Entropy (CCE): saves 8-24GB on large-vocab models
|
||
use_cut_ce: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable Cut Cross-Entropy (CCE) for large-vocab models. "
|
||
"Saves 8-24GB VRAM on Llama 3.1 128k vocab. Requires cut_cross_entropy. "
|
||
"Mutually exclusive with Unsloth/MLX backends."
|
||
),
|
||
)
|
||
# v0.28.0 — Kernel auto-composition (Liger + Unsloth + FlashAttn per-layer)
|
||
kernel_auto_compose: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Benchmark and auto-select the fastest kernel combination "
|
||
"(Liger / FlashAttn / baseline) on the first few steps. (v0.28.0)."
|
||
),
|
||
)
|
||
# v0.28.0 — Cross-document attention masking for sample packing
|
||
packing_cross_doc_attn_mask: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"When packing is enabled, prevent attention bleed between packed "
|
||
"documents. Requires packing=true. (v0.28.0)."
|
||
),
|
||
)
|
||
# v0.28.0 — Activation offloading (CPU/disk) for small-VRAM large-batch
|
||
activation_offloading: Optional[Literal["cpu", "disk"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Offload activations to CPU or disk during backward pass. "
|
||
"None=off, 'cpu'=offload to RAM, 'disk'=offload to tmp file. (v0.28.0)."
|
||
),
|
||
)
|
||
# Embedding-specific
|
||
embedding_loss: Literal["contrastive", "triplet", "cosine"] = Field(
|
||
default="contrastive",
|
||
description="Loss function for embedding training: contrastive, triplet, or cosine",
|
||
)
|
||
embedding_margin: float = Field(
|
||
default=0.5, gt=0,
|
||
description="Margin for contrastive/triplet loss (higher = stricter separation)",
|
||
)
|
||
embedding_pooling: Literal["mean", "cls", "last"] = Field(
|
||
default="mean",
|
||
description="Pooling strategy for sentence embeddings: mean, cls, or last token",
|
||
)
|
||
embedding_temperature: float = Field(
|
||
default=0.05, gt=0,
|
||
description="Temperature for contrastive (InfoNCE) loss — lower = stricter similarity",
|
||
)
|
||
# Curriculum learning — sort dataset by difficulty
|
||
curriculum: bool = Field(
|
||
default=False,
|
||
description="Enable curriculum learning (sort dataset by difficulty, easy → hard)",
|
||
)
|
||
curriculum_metric: Literal["length", "perplexity", "loss"] = Field(
|
||
default="length",
|
||
description="Metric for curriculum difficulty: length, perplexity, or loss",
|
||
)
|
||
curriculum_buckets: int = Field(
|
||
default=4, ge=1, le=20,
|
||
description="Number of difficulty stages for curriculum learning",
|
||
)
|
||
# Curriculum-Aware dynamic re-weighting (v0.48.0 Part A — BETA)
|
||
curriculum_dynamic: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"BETA: dynamically re-weight curriculum buckets every N steps via "
|
||
"online uncertainty estimation (per-sample loss + grad norm). "
|
||
"Requires curriculum=true. Multi-rank launches must wire an "
|
||
"all_reduce hook on per-bucket stats (see "
|
||
"utils.curriculum_dynamic.validate_distributed_curriculum)."
|
||
),
|
||
)
|
||
curriculum_dynamic_recompute_steps: int = Field(
|
||
default=50, ge=1, le=100_000,
|
||
description=(
|
||
"Recompute curriculum bucket sampler weights every N global "
|
||
"training steps."
|
||
),
|
||
)
|
||
curriculum_dynamic_floor: float = Field(
|
||
default=0.05, gt=0.0, le=0.5,
|
||
description=(
|
||
"Minimum normalised per-bucket weight after softmax. "
|
||
"Must be in (0.0, 1/curriculum_buckets]; the cross-validator "
|
||
"tightens this to the per-config ceiling. Prevents bucket "
|
||
"starvation."
|
||
),
|
||
)
|
||
curriculum_dynamic_temperature: float = Field(
|
||
default=1.0, gt=0.0, le=100.0,
|
||
description=(
|
||
"Softmax temperature on the uncertainty signal. Higher = flatter "
|
||
"distribution; lower = concentrate on hardest buckets."
|
||
),
|
||
)
|
||
# Loss watchdog — auto-stop on loss spikes
|
||
loss_watchdog: bool = Field(
|
||
default=False,
|
||
description="Enable loss spike detection (auto-stop if loss exceeds threshold)",
|
||
)
|
||
loss_watchdog_threshold: float = Field(
|
||
default=3.0,
|
||
gt=0,
|
||
le=100.0,
|
||
description="Stop training if loss exceeds this threshold",
|
||
)
|
||
loss_watchdog_patience: int = Field(
|
||
default=5,
|
||
ge=1,
|
||
le=1000,
|
||
description="Consecutive high-loss steps before stopping",
|
||
)
|
||
# Loss spike auto-recovery (v0.32.0 Part E) — extends watchdog
|
||
loss_spike_recovery: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"On watchdog trigger: rollback to last checkpoint, decay LR, "
|
||
"and resume (instead of stopping). Requires loss_watchdog=true."
|
||
),
|
||
)
|
||
loss_spike_recovery_max_attempts: int = Field(
|
||
default=3, ge=1, le=10,
|
||
description="Max number of spike-recovery attempts before giving up",
|
||
)
|
||
loss_spike_recovery_lr_decay: float = Field(
|
||
default=0.5, gt=0.0, lt=1.0,
|
||
description="Multiply LR by this factor on each spike recovery (0.5 = halve)",
|
||
)
|
||
# ReLoRA (v0.39.0 Part B)
|
||
relora_steps: Optional[int] = Field(
|
||
default=None, ge=1, le=10**7,
|
||
description=(
|
||
"Fire ReLoRA magnitude-prune + optimizer reset every N global steps. "
|
||
"None disables. Requires a LoRA-style PEFT (not VeRA)."
|
||
),
|
||
)
|
||
relora_warmup_ratio: float = Field(
|
||
default=0.1, ge=0.0, le=1.0,
|
||
description="Skip ReLoRA firings during the first warmup_ratio fraction of training",
|
||
)
|
||
relora_reset_optimizer: bool = Field(
|
||
default=True,
|
||
description="Clear optimizer state for pruned LoRA params on each ReLoRA fire",
|
||
)
|
||
relora_prune_ratio: float = Field(
|
||
default=0.9, gt=0.0, lt=1.0,
|
||
description=(
|
||
"Fraction of LoRA weights to zero out by magnitude on each fire "
|
||
"(0.9 keeps the top 10%). Must be < 1.0."
|
||
),
|
||
)
|
||
# Convergence detection (v0.32.0 Part F)
|
||
convergence_detection: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Watch for loss plateau / oscillation and surface advice "
|
||
"(continue / early_stop / lower_lr) at the end of training."
|
||
),
|
||
)
|
||
convergence_window: int = Field(
|
||
default=50, ge=5, le=10_000,
|
||
description="Number of recent losses to inspect for plateau / oscillation",
|
||
)
|
||
convergence_rel_tol: float = Field(
|
||
default=0.005, gt=0.0, le=1.0,
|
||
description="Relative range threshold below which the window is a plateau",
|
||
)
|
||
# Warmup auto-schedule (v0.32.0 Part D) — reuses pre-existing warmup_ratio.
|
||
warmup_auto: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Auto-pick warmup_steps from dataset_size × epochs × warmup_ratio. "
|
||
"Overrides any manual warmup_steps in the trainer."
|
||
),
|
||
)
|
||
# Auto mixed-precision (v0.32.0 Part C)
|
||
auto_mixed_precision: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Pick bf16/fp16 based on model + GPU compute capability. "
|
||
"Overrides manual --bf16 / --fp16 trainer flags."
|
||
),
|
||
)
|
||
# Live grad-accum monitoring (v0.32.0 Part B)
|
||
grad_accum_auto_tune: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Monitor VRAM each step; warn (and recommend new batch/accum) "
|
||
"when memory pressure is high. Advisory in v0.32.0; live "
|
||
"DataLoader rebuild deferred to v0.32.1."
|
||
),
|
||
)
|
||
grad_accum_pressure_threshold: float = Field(
|
||
default=0.92, gt=0.05, lt=0.99,
|
||
description="VRAM utilisation fraction that triggers a recommendation",
|
||
)
|
||
# Freeze training — freeze bottom layers for parameter-efficient training
|
||
freeze_layers: Optional[int] = Field(
|
||
default=None,
|
||
ge=1,
|
||
le=1000,
|
||
description="Freeze first N layers (from bottom). Train only remaining layers.",
|
||
)
|
||
freeze_ratio: Optional[float] = Field(
|
||
default=None,
|
||
gt=0.0,
|
||
lt=1.0,
|
||
description="Freeze this fraction of layers (0.75 = freeze 75% from bottom).",
|
||
)
|
||
# Sample packing — pack multiple short samples into one sequence
|
||
packing: bool = Field(
|
||
default=False,
|
||
description="Pack multiple short samples into one sequence for faster training",
|
||
)
|
||
# v0.37.0 — Multipack First-Fit-Decreasing bin-packing sampler
|
||
multipack: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Use FFD bin-packing sampler to maximise tokens-per-batch on "
|
||
"uneven-length data. Mutually exclusive with packing. Only "
|
||
"supported for sft / pretrain tasks (transformers backend). "
|
||
"(v0.37.0)."
|
||
),
|
||
)
|
||
# NEFTune — noisy embeddings for better fine-tuning
|
||
neftune_alpha: Optional[float] = Field(
|
||
default=None,
|
||
ge=0.0,
|
||
le=50.0,
|
||
description="NEFTune noise alpha (0-50). Adds noise to embeddings for better chat quality.",
|
||
)
|
||
# Training Intelligence — Part G of v0.25.0
|
||
# Forgetting detection
|
||
forgetting_detection: bool = Field(
|
||
default=False,
|
||
description="Enable periodic general-knowledge eval to detect catastrophic forgetting",
|
||
)
|
||
forgetting_eval_steps: int = Field(
|
||
default=100, ge=10, le=10000,
|
||
description="Run forgetting eval every N steps",
|
||
)
|
||
forgetting_threshold: float = Field(
|
||
default=0.10, ge=0.01, le=0.50,
|
||
description="Warn if accuracy drops > threshold from baseline (0.01-0.50)",
|
||
)
|
||
forgetting_benchmark: Literal["mini_mmlu", "mini_common_sense", "mini_instruction"] = Field(
|
||
default="mini_mmlu",
|
||
description="Built-in mini benchmark used for forgetting detection",
|
||
)
|
||
forgetting_stop: bool = Field(
|
||
default=False,
|
||
description="Auto-stop training on severe forgetting (red-level alert)",
|
||
)
|
||
# Checkpoint intelligence
|
||
checkpoint_intelligence: bool = Field(
|
||
default=False,
|
||
description="Enable auto-best-checkpoint tracking by quality (not just loss)",
|
||
)
|
||
checkpoint_eval_steps: int = Field(
|
||
default=200, ge=50, le=10000,
|
||
description="Run checkpoint quality eval every N steps",
|
||
)
|
||
checkpoint_eval_metric: Literal["judge", "mmlu", "custom", "composite"] = Field(
|
||
default="composite",
|
||
description="Metric used for checkpoint quality selection",
|
||
)
|
||
checkpoint_eval_tasks: Optional[str] = Field(
|
||
default=None,
|
||
description="Optional JSONL file with custom eval tasks for checkpoint scoring",
|
||
)
|
||
checkpoint_keep_top: int = Field(
|
||
default=3, ge=1, le=20,
|
||
description="Keep top-N checkpoints by quality, delete the rest",
|
||
)
|
||
early_stop_on_regression: bool = Field(
|
||
default=False,
|
||
description="Stop training when quality regresses across consecutive evals",
|
||
)
|
||
early_stop_patience: int = Field(
|
||
default=2, ge=1, le=10,
|
||
description="Consecutive regressions before early stopping (1-10)",
|
||
)
|
||
# Eval-Gated Training — Part B of v0.26.0
|
||
eval_gate: Optional["EvalGateConfig"] = Field(
|
||
default=None,
|
||
description="Optional EvalGateConfig — block training on regressions",
|
||
)
|
||
# Multi-GPU Mastery — v0.27.0
|
||
use_fsdp2_compile: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Enable torch.compile on top of FSDP2 for +20-30% training speed. "
|
||
"Requires --fsdp, CUDA, and backend=transformers."
|
||
),
|
||
)
|
||
parallelism: Literal["data", "pipeline"] = Field(
|
||
default="data",
|
||
description=(
|
||
"Distributed strategy: 'data' (DDP/FSDP/DeepSpeed) or 'pipeline' "
|
||
"(pipeline parallel, v0.27.0 wiring only)."
|
||
),
|
||
)
|
||
pipeline_stages: int = Field(
|
||
default=1, ge=1, le=16,
|
||
description=(
|
||
"Number of pipeline parallel stages. Ignored when "
|
||
"parallelism='data'."
|
||
),
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_verifiable_reward(self) -> "TrainingConfig":
|
||
"""RLVR: reward_fn='verifiable' requires verifiable_domain."""
|
||
if self.reward_fn == "verifiable" and self.verifiable_domain is None:
|
||
raise ValueError(
|
||
"reward_fn='verifiable' requires verifiable_domain "
|
||
"(one of: math, code, json_schema)"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_grpo_stability_pairings(self) -> "TrainingConfig":
|
||
"""v0.50.0 Part D — surfaces probable footguns in stability knobs.
|
||
|
||
``mask_truncated_completions=True`` without ``tis_threshold`` is a
|
||
no-op (the mask is built from the threshold). Reject loudly rather
|
||
than silently no-op (mirrors v0.32.0 spike-recovery + watchdog
|
||
cross-validator policy).
|
||
"""
|
||
if self.mask_truncated_completions and self.tis_threshold is None:
|
||
raise ValueError(
|
||
"mask_truncated_completions requires tis_threshold to be set "
|
||
"(the truncation mask is derived from the importance-sampling "
|
||
"threshold)"
|
||
)
|
||
return self
|
||
|
||
@field_validator(
|
||
"ref_model_ema_alpha",
|
||
"tis_threshold",
|
||
"off_policy_mask_threshold",
|
||
"replay_buffer_size",
|
||
"grpo_delta",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _reject_bool_on_grpo_numerics(cls, v: object, info: object) -> object:
|
||
"""v0.50.0 (tdd-guide HIGH fix) — explicit bool rejection on every
|
||
numeric stability/RL knob. Matches v0.30.0 ``Candidate`` /
|
||
v0.41.0 Part B ``lr_groups`` / v0.43.0 Part B ``Tournament`` policy.
|
||
|
||
Pydantic v2 coerces ``True`` → ``1`` and ``False`` → ``0`` on int /
|
||
float fields by default; that would silently accept a misconfigured
|
||
YAML where a user typed ``true`` instead of a numeric literal.
|
||
"""
|
||
if isinstance(v, bool):
|
||
raise ValueError(
|
||
f"{getattr(info, 'field_name', 'field')} must not be bool"
|
||
)
|
||
return v
|
||
|
||
@field_validator("grpo_delta", mode="after")
|
||
@classmethod
|
||
def _validate_grpo_delta_finite(cls, v: Optional[float]) -> Optional[float]:
|
||
"""v0.50.0 Part A (security review fix) — explicit NaN/Inf rejection.
|
||
|
||
Pydantic's ``gt=0.0, le=1.0`` incidentally rejects NaN (since
|
||
``NaN > 0.0`` is False), but the rejection is implicit. Make it
|
||
explicit so a future Pydantic change cannot regress the guard.
|
||
Mirrors v0.32.0 ``save_lr_finder_report`` / v0.47.0 Part A
|
||
``build_forge_plan`` policy.
|
||
"""
|
||
if v is None:
|
||
return v
|
||
import math as _math
|
||
|
||
if not _math.isfinite(v):
|
||
raise ValueError("grpo_delta must be finite (no NaN/Inf)")
|
||
return v
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_grpo_variant_delta(self) -> "TrainingConfig":
|
||
"""v0.50.0 Part A — grpo_variant='two_sided' requires grpo_delta.
|
||
|
||
Conversely, grpo_delta is only meaningful for the two_sided variant;
|
||
setting it on any other variant (or with no variant) is rejected
|
||
as a probable footgun (matches v0.40.0 Part D ``preference_loss_weights``
|
||
+ ``preference_loss`` mutually-exclusive policy).
|
||
"""
|
||
if self.grpo_variant == "two_sided" and self.grpo_delta is None:
|
||
raise ValueError(
|
||
"grpo_variant='two_sided' requires grpo_delta "
|
||
"(symmetric clipping radius, (0, 1])"
|
||
)
|
||
if self.grpo_delta is not None and self.grpo_variant != "two_sided":
|
||
raise ValueError(
|
||
"grpo_delta is only valid when grpo_variant='two_sided'; "
|
||
f"got grpo_variant={self.grpo_variant!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_cross_doc_attn_mask(self) -> "TrainingConfig":
|
||
"""Cross-document attention masking requires packing=True."""
|
||
if self.packing_cross_doc_attn_mask and not self.packing:
|
||
raise ValueError(
|
||
"packing_cross_doc_attn_mask requires packing=true "
|
||
"(cross-doc attention masking only applies to packed sequences)"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_multipack_packing_exclusive(self) -> "TrainingConfig":
|
||
"""Multipack and packing are mutually exclusive — pick one (v0.37.0).
|
||
|
||
Both rewrite the batch composition; running them together produces
|
||
ill-defined sample boundaries. Plan: long term, multipack subsumes
|
||
packing — but for v0.37.0 we keep them as separate opt-ins.
|
||
"""
|
||
if self.multipack and self.packing:
|
||
raise ValueError(
|
||
"multipack and packing are mutually exclusive — "
|
||
"pick one (multipack uses FFD bin-packing; packing "
|
||
"uses TRL's basic packer). For most uses, multipack=true "
|
||
"is the better choice."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_curriculum_dynamic_requires_curriculum(self) -> "TrainingConfig":
|
||
"""v0.48.0 Part A — dynamic re-weighting layers on the static
|
||
curriculum bucketer; it cannot run alone."""
|
||
if self.curriculum_dynamic and not self.curriculum:
|
||
raise ValueError(
|
||
"curriculum_dynamic requires curriculum=true "
|
||
"(dynamic re-weighting needs the static bucketer)."
|
||
)
|
||
# Cross-check: floor must leave room above uniform/N.
|
||
if self.curriculum_dynamic:
|
||
ceiling = 1.0 / max(self.curriculum_buckets, 1)
|
||
if self.curriculum_dynamic_floor > ceiling:
|
||
raise ValueError(
|
||
f"curriculum_dynamic_floor={self.curriculum_dynamic_floor} "
|
||
f"must be <= 1/curriculum_buckets ({ceiling:.4f})."
|
||
)
|
||
return self
|
||
|
||
@field_validator(
|
||
"yarn_factor",
|
||
"yarn_attn_factor",
|
||
"yarn_beta_fast",
|
||
"yarn_beta_slow",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _reject_bool_yarn(cls, value: Any) -> Any:
|
||
"""v0.49.0 Part A — bool is a subclass of int/float in Python; Pydantic
|
||
would silently accept ``True``. Reject explicitly (project bool-as-int
|
||
policy, mirrors v0.30.0 Candidate / v0.34.0 estimate_run_cost_usd)."""
|
||
if isinstance(value, bool):
|
||
raise ValueError(
|
||
"bool is not a valid value for a YaRN tunable (use a real number)"
|
||
)
|
||
return value
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_yarn_fields_require_yarn_type(self) -> "TrainingConfig":
|
||
"""v0.49.0 Part A — yarn_* fields are no-ops unless
|
||
``rope_scaling_type='yarn'``. Surface the misconfig loudly at config
|
||
load rather than silently dropping the values.
|
||
"""
|
||
yarn_fields = {
|
||
"yarn_factor": self.yarn_factor,
|
||
"yarn_attn_factor": self.yarn_attn_factor,
|
||
"yarn_beta_fast": self.yarn_beta_fast,
|
||
"yarn_beta_slow": self.yarn_beta_slow,
|
||
}
|
||
set_fields = [name for name, value in yarn_fields.items() if value is not None]
|
||
if set_fields and self.rope_scaling_type != "yarn":
|
||
raise ValueError(
|
||
f"{', '.join(set_fields)} only apply when rope_scaling_type='yarn' "
|
||
f"(got rope_scaling_type={self.rope_scaling_type!r})."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_longlora_ring_attn_exclusive(self) -> "TrainingConfig":
|
||
"""v0.49.0 Part C — LongLoRA's S² shifted-sparse attention is a custom
|
||
forward override that conflicts with ring/FA-v3 custom-mask attention
|
||
paths."""
|
||
if self.use_longlora and self.use_ring_attention:
|
||
raise ValueError(
|
||
"use_longlora is incompatible with use_ring_attention "
|
||
"(both rewrite the attention kernel — pick one)."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_spike_recovery_requires_watchdog(self) -> "TrainingConfig":
|
||
"""Spike recovery is a watchdog hook — it needs the watchdog enabled."""
|
||
if self.loss_spike_recovery and not self.loss_watchdog:
|
||
raise ValueError(
|
||
"loss_spike_recovery requires loss_watchdog=true "
|
||
"(spike recovery is triggered by the watchdog)"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_prequantized_no_qat(self) -> "TrainingConfig":
|
||
"""v0.38.0 — Pre-quantized formats + QAT is incompatible.
|
||
|
||
GPTQ / AWQ / HQQ / AQLM / EETQ / MXFP4 / FP8 checkpoints all carry
|
||
their own scale; routing them through torchao QAT or float8 prepare
|
||
would corrupt the dequantized weights. Mirrors LlamaFactory's similar
|
||
guard at quantization.py:117 / :199 / :211.
|
||
"""
|
||
from soup_cli.utils.quant_menu import is_quant_menu_format
|
||
|
||
if is_quant_menu_format(self.quantization) and self.quantization_aware:
|
||
raise ValueError(
|
||
f"quantization={self.quantization!r} is incompatible with "
|
||
f"quantization_aware ({self.quantization_aware!r}). "
|
||
"Pre-quantized checkpoints carry their own scale; "
|
||
"QAT/FP8 prepare cannot compose. Set quantization_aware: false."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_bnb_quant_storage_only_with_4bit(self) -> "TrainingConfig":
|
||
"""v0.38.0 Part G — bnb_4bit_quant_storage applies only to BNB 4-bit
|
||
and MXFP4 (which is a BNB 4-bit variant). Setting it on any other
|
||
format is a silent no-op — fail fast.
|
||
"""
|
||
if self.bnb_4bit_quant_storage is None:
|
||
return self
|
||
if self.quantization not in ("4bit", "mxfp4"):
|
||
raise ValueError(
|
||
f"bnb_4bit_quant_storage={self.bnb_4bit_quant_storage!r} "
|
||
f"requires quantization in {{'4bit', 'mxfp4'}}, got "
|
||
f"{self.quantization!r}."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_fp8_recipe_requires_fp8(self) -> "TrainingConfig":
|
||
"""fp8_recipe is only meaningful when quantization_aware='fp8'."""
|
||
if self.fp8_recipe != "tensorwise" and self.quantization_aware != "fp8":
|
||
raise ValueError(
|
||
f"fp8_recipe='{self.fp8_recipe}' requires quantization_aware='fp8'. "
|
||
"Either set quantization_aware: 'fp8' or remove the fp8_recipe field."
|
||
)
|
||
return self
|
||
|
||
@field_validator("optimizer")
|
||
@classmethod
|
||
def _validate_optimizer(cls, value: str) -> str:
|
||
"""v0.41.0 Part A — optimizer allowlist."""
|
||
from soup_cli.utils.optimizer_zoo import validate_optimizer_name
|
||
|
||
return validate_optimizer_name(value)
|
||
|
||
@field_validator("lr_groups", mode="before")
|
||
@classmethod
|
||
def _validate_lr_groups(cls, value):
|
||
"""v0.41.0 Part B — parse + validate lr_groups."""
|
||
if value is None:
|
||
return None
|
||
from soup_cli.utils.lr_groups import parse_lr_groups
|
||
|
||
parsed = parse_lr_groups(value)
|
||
if parsed is None:
|
||
return None
|
||
# Re-emit as the raw schema shape (list of {pattern, lr} dicts) so
|
||
# round-tripping through model_dump preserves user-visible structure.
|
||
return [{"pattern": g.pattern, "lr": g.lr} for g in parsed]
|
||
|
||
@field_validator("freeze_trainable_layers", mode="before")
|
||
@classmethod
|
||
def _validate_freeze_trainable_layers(cls, value):
|
||
"""v0.41.0 Part C — magnitude capped at 1000."""
|
||
if value is None:
|
||
return None
|
||
from soup_cli.utils.block_expansion import (
|
||
validate_freeze_trainable_layers,
|
||
)
|
||
|
||
return validate_freeze_trainable_layers(value)
|
||
|
||
@field_validator("expand_layers", mode="before")
|
||
@classmethod
|
||
def _validate_expand_layers_field(cls, value):
|
||
"""v0.41.0 Part C — block expansion bounds + bool rejection.
|
||
|
||
Pydantic's `Field(ge=1, le=64)` accepts ``True`` (subclass of int);
|
||
the explicit validator rejects bool and routes through the shared
|
||
helper so the int bounds stay single-source-of-truth.
|
||
"""
|
||
if value is None:
|
||
return None
|
||
from soup_cli.utils.block_expansion import validate_expand_layers
|
||
|
||
return validate_expand_layers(value)
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_load_in_aliases(self) -> "TrainingConfig":
|
||
"""v0.41.0 Part C — load_in_8bit / load_in_16bit aliases.
|
||
|
||
Mutually exclusive. When set to True, they override ``quantization``
|
||
only if the user did not explicitly pick a Quant Menu format
|
||
(gptq / awq / hqq:* / aqlm / eetq / mxfp4 / fp8). Mixing alias=True
|
||
with Quant Menu raises rather than silently overriding the explicit
|
||
pick. Uses ``is True`` (project policy) so an explicit ``False``
|
||
from the user is treated as "no preference", never silently
|
||
rewriting the field.
|
||
"""
|
||
l8 = self.load_in_8bit
|
||
l16 = self.load_in_16bit
|
||
if l8 is True and l16 is True:
|
||
raise ValueError(
|
||
"load_in_8bit and load_in_16bit are mutually exclusive — "
|
||
"pick one."
|
||
)
|
||
if l8 is not True and l16 is not True:
|
||
return self
|
||
# Defer the import: utils.quant_menu is loaded lazily elsewhere.
|
||
from soup_cli.utils.quant_menu import is_quant_menu_format
|
||
|
||
if is_quant_menu_format(self.quantization):
|
||
raise ValueError(
|
||
f"load_in_8bit / load_in_16bit cannot be combined with "
|
||
f"quantization={self.quantization!r} (Quant Menu format). "
|
||
"Either remove the alias or set quantization to '4bit', "
|
||
"'8bit', or 'none'."
|
||
)
|
||
# Direct assignment routes through Pydantic v2 BaseModel.__setattr__
|
||
# so any future field_validator on ``quantization`` still fires.
|
||
# ``object.__setattr__`` would silently bypass that path.
|
||
if l8 is True and self.quantization != "8bit":
|
||
self.quantization = "8bit"
|
||
elif l16 is True and self.quantization != "none":
|
||
self.quantization = "none"
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_block_expansion_pair(self) -> "TrainingConfig":
|
||
"""v0.41.0 Part C — expand_layers + freeze_trainable_layers pair."""
|
||
if self.expand_layers is not None and self.freeze_trainable_layers is None:
|
||
raise ValueError(
|
||
"expand_layers requires freeze_trainable_layers (LLaMA Pro "
|
||
"freezes the original layers and trains only the new blocks). "
|
||
"Set freeze_trainable_layers: <signed int>."
|
||
)
|
||
return self
|
||
|
||
# ---- v0.61.0 Part A — Unlearning ---------------------------------------
|
||
# Schema-only release: validators here are reused by the SoupConfig
|
||
# cross-validator + UnlearnTrainerWrapper. Live trainer in v0.61.1.
|
||
unlearn_method: Optional[Literal["npo", "simnpo", "rmu"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Unlearning method backend — required when task='unlearn'. "
|
||
"npo (Negative Preference Optimization, DPO-shaped negative-only "
|
||
"loss); simnpo (length-normalised NPO without ref model); rmu "
|
||
"(Representation Misdirection Unlearning, residual-stream noise). "
|
||
"Schema-only in v0.61.0; live trainer deferred to v0.61.1."
|
||
),
|
||
)
|
||
unlearn_alpha: Optional[float] = Field(
|
||
default=None,
|
||
description=(
|
||
"Retain-set weighting in the unlearn loss (forget vs retain "
|
||
"mixing coefficient). 0.0 = pure forget loss; higher values "
|
||
"increasingly favour the retain set. Bounded [0.0, 10.0]. "
|
||
"(v0.61.0)"
|
||
),
|
||
)
|
||
|
||
@field_validator("unlearn_method", mode="before")
|
||
@classmethod
|
||
def _validate_unlearn_method(cls, v):
|
||
"""v0.61.0 Part A — bool / null-byte / oversize / case-insensitive
|
||
normalisation via the shared helper.
|
||
|
||
Mirrors v0.51.0 ``_normalize_hub`` / v0.52.0 ``_validate_reasoning_effort``
|
||
policy of routing through the public ``validate_*`` helper at
|
||
``mode='before'`` so the schema and runtime helper agree on what's
|
||
accepted.
|
||
"""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.unlearning import validate_unlearn_method
|
||
|
||
return validate_unlearn_method(v)
|
||
|
||
@field_validator("unlearn_alpha", mode="before")
|
||
@classmethod
|
||
def _validate_unlearn_alpha(cls, v):
|
||
"""v0.61.0 Part A — bool/NaN/Inf-rejected float bounded [0.0, 10.0]."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.unlearning import validate_unlearn_alpha
|
||
|
||
return validate_unlearn_alpha(v)
|
||
|
||
# ---- v0.62.0 Part B — RA-DIT (Retrieval-Augmented Dual Instruction
|
||
# Tuning, Meta 2023). Schema-only: a YAML can declare ``ra_dit_stage``
|
||
# so a recipe locks the right pairing; live two-stage orchestration
|
||
# ships in v0.62.1 (mirrors the v0.50.0 / v0.61.0 stub-then-live
|
||
# pattern).
|
||
ra_dit_stage: Optional[Literal["retriever", "generator"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"RA-DIT pipeline stage. 'retriever' trains the sentence-"
|
||
"transformer via the v0.16 embedding trainer; 'generator' "
|
||
"runs RAFT-style SFT on `data.format='raft'`. Composes with "
|
||
"the v0.62.0 Part A RAFT recipe. (v0.62.0 Part B)"
|
||
),
|
||
)
|
||
ra_dit_retriever_model: Optional[str] = Field(
|
||
default=None,
|
||
description=(
|
||
"Optional retriever model id (e.g. "
|
||
"`sentence-transformers/all-mpnet-base-v2`) used by the "
|
||
"generator stage to pre-encode distractor docs. (v0.62.0 "
|
||
"Part B)"
|
||
),
|
||
)
|
||
|
||
@field_validator("ra_dit_stage", mode="before")
|
||
@classmethod
|
||
def _validate_ra_dit_stage(cls, v):
|
||
"""v0.62.0 Part B — case-insensitive normalisation via shared helper."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.ra_dit import validate_ra_dit_stage
|
||
|
||
return validate_ra_dit_stage(v)
|
||
|
||
@field_validator("ra_dit_retriever_model", mode="before")
|
||
@classmethod
|
||
def _validate_ra_dit_retriever_model(cls, v):
|
||
"""v0.62.0 Part B — bool/null-byte/oversize rejection on retriever id."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.ra_dit import validate_ra_dit_retriever_model
|
||
|
||
return validate_ra_dit_retriever_model(v)
|
||
|
||
# ---- v0.62.0 Part D — Citation-faithful FT ----------------------------
|
||
citation_faithful: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Opt INTO citation-precision / recall scoring + a loss-mask "
|
||
"rule that emphasises citation spans. Requires "
|
||
"`data.format='raft'`. Schema-only in v0.62.0; live span-mask "
|
||
"ships in v0.62.1. (v0.62.0 Part D)"
|
||
),
|
||
)
|
||
citation_style: Optional[Literal["bracket", "inline", "footnote"]] = Field(
|
||
default=None,
|
||
description=(
|
||
"Citation rendering style. 'bracket' = `[doc-1]` inline tag "
|
||
"(canonical RAFT default); 'inline' / 'footnote' are stub "
|
||
"placeholders for v0.62.1. (v0.62.0 Part D)"
|
||
),
|
||
)
|
||
citation_recall_threshold: Optional[float] = Field(
|
||
default=None,
|
||
description=(
|
||
"Reject final-save when measured citation recall < this "
|
||
"threshold. Bounded [0.0, 1.0]. Composes with v0.56.0 "
|
||
"diagnose-gate. (v0.62.0 Part D)"
|
||
),
|
||
)
|
||
|
||
@field_validator("citation_style", mode="before")
|
||
@classmethod
|
||
def _validate_citation_style(cls, v):
|
||
"""v0.62.0 Part D — case-insensitive normalisation via shared helper."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.citation_faithful import validate_citation_style
|
||
|
||
return validate_citation_style(v)
|
||
|
||
@field_validator("citation_recall_threshold", mode="before")
|
||
@classmethod
|
||
def _validate_citation_recall_threshold(cls, v):
|
||
"""v0.62.0 Part D — bool/NaN/Inf-rejected float bounded [0.0, 1.0]."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.citation_faithful import validate_citation_threshold
|
||
|
||
return validate_citation_threshold(v)
|
||
|
||
# ---- v0.62.0 Part E — GRACE codebook ----------------------------------
|
||
grace_codebook: bool = Field(
|
||
default=False,
|
||
description=(
|
||
"Opt INTO the GRACE codebook — discrete latent-space (key, "
|
||
"value) store for thousands of sequential knowledge edits "
|
||
"without norm-blowup. Schema-only in v0.62.0; live lookup / "
|
||
"write ships in v0.62.1. (v0.62.0 Part E)"
|
||
),
|
||
)
|
||
grace_codebook_size: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Codebook entry count. Required when grace_codebook=True. "
|
||
"Bounded [1, 100_000]. (v0.62.0 Part E)"
|
||
),
|
||
)
|
||
grace_codebook_dim: Optional[int] = Field(
|
||
default=None,
|
||
description=(
|
||
"Codebook entry dim (residual-stream width). Required when "
|
||
"grace_codebook=True. Bounded [1, 16_384]. (v0.62.0 Part E)"
|
||
),
|
||
)
|
||
|
||
@field_validator("grace_codebook_size", mode="before")
|
||
@classmethod
|
||
def _validate_grace_codebook_size(cls, v):
|
||
"""v0.62.0 Part E — bool-rejected positive int <= MAX_CODEBOOK_SIZE."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.grace_codebook import validate_grace_codebook_size
|
||
|
||
return validate_grace_codebook_size(v)
|
||
|
||
@field_validator("grace_codebook_dim", mode="before")
|
||
@classmethod
|
||
def _validate_grace_codebook_dim(cls, v):
|
||
"""v0.62.0 Part E — bool-rejected positive int <= MAX_CODEBOOK_DIM."""
|
||
if v is None:
|
||
return None
|
||
from soup_cli.utils.grace_codebook import validate_grace_codebook_dim
|
||
|
||
return validate_grace_codebook_dim(v)
|
||
|
||
|
||
class EvalConfig(BaseModel):
|
||
"""Evaluation configuration for auto-eval after training."""
|
||
|
||
auto_eval: bool = Field(
|
||
default=False,
|
||
description="Run evaluation automatically after training completes",
|
||
)
|
||
benchmarks: Optional[List[str]] = Field(
|
||
default=None,
|
||
description="lm-evaluation-harness benchmark names to run",
|
||
)
|
||
custom_tasks: Optional[str] = Field(
|
||
default=None,
|
||
description="Path to custom eval JSONL file",
|
||
)
|
||
judge: Optional[dict] = Field(
|
||
default=None,
|
||
description="LLM-as-a-judge config: model, rubric, provider",
|
||
)
|
||
|
||
|
||
class SoupConfig(BaseModel):
|
||
"""Root config for soup.yaml."""
|
||
|
||
base: str = Field(..., description="Base model name or path (HF model ID)")
|
||
task: Literal[
|
||
"sft", "dpo", "grpo", "ppo", "reward_model", "kto", "orpo", "simpo", "ipo",
|
||
"bco", "preference", "pretrain", "embedding", "prm",
|
||
# v0.52.0 Modality II — TTS / classifier-family / distillation.
|
||
"tts", "classifier", "reranker", "cross_encoder", "distill",
|
||
# v0.61.0 Part A — Unlearning (NPO / SimNPO / RMU).
|
||
"unlearn",
|
||
# v0.67.0 Part C — MoLE per-token adapter routing (Mixture of LoRA Experts).
|
||
"moe_lora_routing",
|
||
] = Field(
|
||
default="sft",
|
||
description=(
|
||
"Training task type. v0.50.0 Part E added 'prm'; v0.52.0 adds "
|
||
"'tts' (TTS fine-tuning), 'classifier' / 'reranker' / "
|
||
"'cross_encoder' (classification heads), and 'distill' "
|
||
"(knowledge distillation). v0.61.0 adds 'unlearn' (NPO / "
|
||
"SimNPO / RMU). v0.67.0 adds 'moe_lora_routing' (per-token "
|
||
"gating over N task LoRAs)."
|
||
),
|
||
)
|
||
modality: Literal["text", "vision", "audio", "audio_out"] = Field(
|
||
default="text",
|
||
description=(
|
||
"Training modality: text (default), vision (multimodal), audio "
|
||
"(audio-input), or audio_out (audio-output — paired with task='tts', "
|
||
"v0.52.0)."
|
||
),
|
||
)
|
||
backend: Literal["transformers", "unsloth", "mlx"] = Field(
|
||
default="transformers",
|
||
description=(
|
||
"Training backend: transformers (default), unsloth (2-5x faster on "
|
||
"CUDA), or mlx (Apple Silicon M1-M4)"
|
||
),
|
||
)
|
||
data: DataConfig
|
||
training: TrainingConfig = Field(default_factory=TrainingConfig)
|
||
output: str = Field(default="./output", description="Output directory for trained model")
|
||
experiment_name: Optional[str] = Field(default=None, description="Experiment name for tracking")
|
||
eval: Optional[EvalConfig] = Field(
|
||
default=None,
|
||
description="Evaluation configuration for auto-eval after training",
|
||
)
|
||
advise: Optional[AdviseConfig] = Field(
|
||
default=None,
|
||
description=(
|
||
"Pre-flight decision settings consumed by `soup advise` "
|
||
"(v0.54.0 — schema-only on SoupConfig)."
|
||
),
|
||
)
|
||
|
||
@field_validator("experiment_name")
|
||
@classmethod
|
||
def experiment_name_safe(cls, value: Optional[str]) -> Optional[str]:
|
||
"""Disallow path separators and null bytes in experiment_name."""
|
||
if value is None:
|
||
return value
|
||
if re.search(r'[/\\:\x00]', value):
|
||
raise ValueError(
|
||
"experiment_name must not contain path separators (/ \\ :) or null bytes"
|
||
)
|
||
return value
|
||
|
||
@model_validator(mode="before")
|
||
@classmethod
|
||
def _remap_root_level_misplaced_keys(cls, values):
|
||
"""v0.40.1 Part B — QA finding C2: users naturally write top-level
|
||
``lora:`` (LlamaFactory / Axolotl convention) but Soup nests it
|
||
under ``training``. Without remap, Pydantic silently drops the
|
||
misplaced key — including ``lora.init_strategy`` validation.
|
||
|
||
Migrate root-level ``lora`` into ``training.lora`` so nested
|
||
validation (Literal["random","pissa","olora"]) actually fires.
|
||
|
||
Caller's dict is never mutated — we work on shallow copies, matching
|
||
v0.33.0 #47 / v0.40.0 Part B immutability policy.
|
||
"""
|
||
if not isinstance(values, dict):
|
||
return values
|
||
# Detect any misplaced key first so we avoid copying when not needed.
|
||
misplaced_keys = [k for k in ("lora",) if k in values]
|
||
if not misplaced_keys:
|
||
return values
|
||
new_values = dict(values)
|
||
new_training = dict(new_values.get("training") or {})
|
||
for misplaced in misplaced_keys:
|
||
if misplaced in new_training:
|
||
raise ValueError(
|
||
f"{misplaced!r} found at both root and training level — "
|
||
f"keep only one (training.{misplaced} preferred)."
|
||
)
|
||
new_training[misplaced] = new_values.pop(misplaced)
|
||
new_values["training"] = new_training
|
||
return new_values
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_v028_speed_memory_supported_tasks(self) -> "SoupConfig":
|
||
"""v0.28.0 speed/memory features: every transformer-backend trainer
|
||
is wired in v0.35.0 (#60). MLX backend trainers are still
|
||
unsupported. Emit a precise ValueError that names the actual reason
|
||
(MLX backend vs unknown task) so users get the right fix.
|
||
"""
|
||
from soup_cli.utils.v028_features import supports_v028_features
|
||
|
||
if supports_v028_features(self.task) and self.backend != "mlx":
|
||
return self
|
||
tcfg = self.training
|
||
offenders: list[str] = []
|
||
if tcfg.use_cut_ce:
|
||
offenders.append("use_cut_ce")
|
||
if tcfg.quantization_aware == "fp8":
|
||
offenders.append('quantization_aware="fp8"')
|
||
if tcfg.activation_offloading is not None:
|
||
offenders.append("activation_offloading")
|
||
if tcfg.kernel_auto_compose:
|
||
offenders.append("kernel_auto_compose")
|
||
if not offenders:
|
||
return self
|
||
# Distinct reasons get distinct messages so users don't waste time
|
||
# blaming MLX when their task is the actual offender.
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
f"v0.28.0 features {offenders} are not supported on the "
|
||
f"Apple Silicon mlx backend (no equivalent kernels). "
|
||
"Switch to backend='transformers' or remove these flags."
|
||
)
|
||
raise ValueError(
|
||
f"v0.28.0 features {offenders} are not wired for "
|
||
f"task={self.task!r}. Supported tasks: see "
|
||
"soup_cli.utils.v028_features.supports_v028_features."
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_multipack_supported_tasks(self) -> "SoupConfig":
|
||
"""v0.37.0 — multipack only ships for sft / pretrain on transformers.
|
||
|
||
Multipack rewrites the DataLoader sampler; preference / RLHF tasks
|
||
in v0.37.0 still use the per-pair sampler shape from TRL. MLX
|
||
backend has its own DataLoader path and is not wired.
|
||
"""
|
||
if not self.training.multipack:
|
||
return self
|
||
from soup_cli.utils.multipack import supports_multipack
|
||
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"multipack=true is not supported on the mlx backend "
|
||
"in v0.37.0 (sampler injection is HF Trainer-specific). "
|
||
"Use backend='transformers' or set multipack: false."
|
||
)
|
||
if not supports_multipack(self.task):
|
||
raise ValueError(
|
||
f"multipack=true is not supported for task={self.task!r} "
|
||
"in v0.37.0 (only sft and pretrain are wired). "
|
||
"Set multipack: false or switch task."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_curriculum_dynamic_supported(self) -> "SoupConfig":
|
||
"""v0.48.0 Part A — Curriculum-Aware dynamic re-weighting.
|
||
|
||
BETA: v0.53.5 #115 widens the allowlist to every transformer-backend
|
||
trainer (sft / pretrain / dpo / grpo / kto / orpo / simpo / ipo / bco /
|
||
reward_model / embedding / ppo / preference) — the v0.53.5
|
||
DynamicCurriculumCallback is shared via ``utils.peft_wiring``.
|
||
MLX backend remains rejected (callback is HF Trainer-specific).
|
||
"""
|
||
if not self.training.curriculum_dynamic:
|
||
return self
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"curriculum_dynamic is not supported on the mlx backend "
|
||
"(callback is HF Trainer-specific). "
|
||
"Use backend='transformers' or set curriculum_dynamic: false."
|
||
)
|
||
supported = {
|
||
"sft", "pretrain", "dpo", "grpo", "kto", "orpo", "simpo", "ipo",
|
||
"bco", "reward_model", "embedding", "ppo", "preference",
|
||
}
|
||
if self.task not in supported:
|
||
raise ValueError(
|
||
f"curriculum_dynamic is not supported for task={self.task!r} "
|
||
"(only transformer-backend trainers in v0.53.5). "
|
||
"Set curriculum_dynamic: false or switch task."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_longlora_compat(self) -> "SoupConfig":
|
||
"""v0.49.0 Part C — LongLoRA S² shifted-sparse attention requires
|
||
``task=sft``, ``backend=transformers``, and a Llama-family base.
|
||
|
||
Live forward override is deferred to v0.49.1 (mirrors v0.27.0 MII /
|
||
v0.37.0 multipack stub-then-live pattern); the schema gate prevents
|
||
misconfiguration today.
|
||
"""
|
||
if not self.training.use_longlora:
|
||
return self
|
||
from soup_cli.utils.longlora import validate_longlora_compat
|
||
|
||
try:
|
||
validate_longlora_compat(
|
||
model_name=self.base,
|
||
task=self.task,
|
||
backend=self.backend,
|
||
use_ring_attention=self.training.use_ring_attention,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_grpo_variant_supported(self) -> "SoupConfig":
|
||
"""v0.50.0 Part A — ``grpo_variant`` only valid on task='grpo' and
|
||
transformers/unsloth backends. MLX rejected with distinct message
|
||
(matches v0.34.0 review-fix policy of distinct error reasons).
|
||
|
||
Live loss kernels for non-standard variants are deferred to v0.50.1;
|
||
a yellow advisory at trainer construction time will name the
|
||
deferred wiring (mirrors v0.40.0 Part D ``NotImplementedError``
|
||
stub-then-live pattern).
|
||
"""
|
||
if self.training.grpo_variant is None:
|
||
return self
|
||
if self.task != "grpo":
|
||
raise ValueError(
|
||
f"grpo_variant is only valid when task='grpo'; "
|
||
f"got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"grpo_variant is not supported on backend=mlx in v0.50.0 "
|
||
"(MLX GRPO is scaffolded; new RL objectives transformers-only)"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_long_context_grpo(self) -> "SoupConfig":
|
||
"""v0.50.0 Part B — ``long_context_grpo`` compatibility gate.
|
||
|
||
Delegates to :func:`grpo_long_context.validate_long_context_grpo_compat`
|
||
so the rules are single-source-of-truth (mirrors v0.49.0 LongLoRA).
|
||
Live Tiled MLP wiring is deferred to v0.56.0.
|
||
"""
|
||
if not self.training.long_context_grpo:
|
||
return self
|
||
from soup_cli.utils.grpo_long_context import (
|
||
validate_long_context_grpo_compat,
|
||
)
|
||
|
||
try:
|
||
validate_long_context_grpo_compat(
|
||
task=self.task,
|
||
backend=self.backend,
|
||
use_ring_attention=self.training.use_ring_attention,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_prm_compat(self) -> "SoupConfig":
|
||
"""v0.50.0 Part E — ``task='prm'`` schema gate.
|
||
|
||
Delegates to :func:`prm.validate_prm_compat` so the rules are
|
||
single-source-of-truth. Live PRM trainer wrapper is deferred to
|
||
v0.50.1.
|
||
"""
|
||
if self.task != "prm":
|
||
return self
|
||
from soup_cli.utils.prm import validate_prm_compat
|
||
|
||
try:
|
||
validate_prm_compat(
|
||
task=self.task,
|
||
data_format=self.data.format,
|
||
backend=self.backend,
|
||
modality=self.modality,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_vision_grpo(self) -> "SoupConfig":
|
||
"""v0.50.0 Part E — ``vision_grpo=True`` compat gate."""
|
||
if not self.training.vision_grpo:
|
||
return self
|
||
from soup_cli.utils.prm import validate_vision_grpo_compat
|
||
|
||
try:
|
||
validate_vision_grpo_compat(
|
||
task=self.task,
|
||
modality=self.modality,
|
||
backend=self.backend,
|
||
base=self.base, # v0.53.3 #129 — name-regex VLM probe
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_grpo_stability_task_gate(self) -> "SoupConfig":
|
||
"""v0.50.0 Part D — GRPO-specific stability knobs require task='grpo'.
|
||
|
||
Surfaces a probable footgun where a user sets one of the seven new
|
||
GRPO stability fields on a non-GRPO task (where they are a silent
|
||
no-op). Mirrors v0.49.0 LongLoRA / v0.48.0 curriculum_dynamic
|
||
task-gate policy.
|
||
"""
|
||
tcfg = self.training
|
||
grpo_only_fields = {
|
||
"ref_model_ema_alpha": tcfg.ref_model_ema_alpha,
|
||
"replay_buffer_size": tcfg.replay_buffer_size,
|
||
"async_grpo_prefetch": tcfg.async_grpo_prefetch,
|
||
"tis_threshold": tcfg.tis_threshold,
|
||
"mask_truncated_completions": tcfg.mask_truncated_completions,
|
||
"defer_rerolling": tcfg.defer_rerolling,
|
||
"skip_zero_advantage": tcfg.skip_zero_advantage,
|
||
"off_policy_mask_threshold": tcfg.off_policy_mask_threshold,
|
||
"grpo_fp16": tcfg.grpo_fp16,
|
||
}
|
||
# Bool defaults are False; Optional defaults are None.
|
||
active = [
|
||
name for name, value in grpo_only_fields.items()
|
||
if value not in (None, False)
|
||
]
|
||
if not active:
|
||
return self
|
||
if self.task != "grpo":
|
||
raise ValueError(
|
||
f"GRPO stability fields {active} require task='grpo'; "
|
||
f"got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
f"GRPO stability fields {active} are not supported on "
|
||
"backend=mlx in v0.50.0"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_grpo_fp16_amp_exclusive(self) -> "SoupConfig":
|
||
"""v0.53.3 #128 — ``grpo_fp16`` and ``auto_mixed_precision`` are
|
||
mutually exclusive.
|
||
|
||
Both flags pick the mixed-precision dtype but go through different
|
||
codepaths (``grpo_fp16`` forces ``fp16=True, bf16=False`` on
|
||
GRPOConfig directly; ``auto_mixed_precision`` runs the v0.32.0
|
||
per-model + per-GPU picker). Combining them is a footgun where the
|
||
downstream behaviour depends on order-of-evaluation — fail fast at
|
||
config-load with a friendly message naming both flags so the user
|
||
picks one.
|
||
"""
|
||
# Short-circuit when task is not 'grpo' so the v0.50.0 stability
|
||
# task-gate error fires first (code-review HIGH fix — keeps a
|
||
# consistent "wrong-task" diagnosis ahead of the mutual-exclusion
|
||
# one, regardless of validator execution order).
|
||
if self.task != "grpo":
|
||
return self
|
||
if self.training.grpo_fp16 and self.training.auto_mixed_precision:
|
||
raise ValueError(
|
||
"grpo_fp16=True and auto_mixed_precision=True are mutually "
|
||
"exclusive — both pick the mixed-precision dtype but go "
|
||
"through different codepaths. Pick one: grpo_fp16 forces "
|
||
"FP16 (unsloth parity), auto_mixed_precision uses the "
|
||
"v0.32.0 per-GPU picker."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_hub_supported(self) -> "SoupConfig":
|
||
"""v0.51.0 Part E — ``hub`` other than ``hf`` requires a non-mlx
|
||
backend.
|
||
|
||
``mlx-lm`` has no ModelScope/Modelers download integration, so a
|
||
config that pairs ``backend: mlx`` + ``hub: modelscope`` would fail
|
||
at runtime with a confusing ``mlx-lm`` error. Reject loudly at
|
||
config-load with a distinct message (matches v0.34.0 review-fix
|
||
policy).
|
||
"""
|
||
if self.training.hub == "hf":
|
||
return self
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
f"hub={self.training.hub!r} is not supported on "
|
||
"backend=mlx (mlx-lm only downloads from HF Hub). "
|
||
"Use hub='hf' on the mlx backend."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_tts_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part A — ``task='tts'`` gate."""
|
||
tcfg = self.training
|
||
if self.task != "tts" and tcfg.tts_family is None and tcfg.tts_emotion is None:
|
||
return self
|
||
if self.task == "tts":
|
||
from soup_cli.utils.tts import (
|
||
validate_emotion_tag,
|
||
validate_tts_compat,
|
||
)
|
||
|
||
try:
|
||
validate_tts_compat(
|
||
task=self.task,
|
||
modality=self.modality,
|
||
backend=self.backend,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
if tcfg.tts_family is None:
|
||
raise ValueError(
|
||
"task='tts' requires training.tts_family in "
|
||
"(orpheus, sesame_csm, llasa, spark, oute)"
|
||
)
|
||
if tcfg.tts_emotion is not None:
|
||
try:
|
||
validate_emotion_tag(tcfg.tts_emotion, family=tcfg.tts_family)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
# tts_family / tts_emotion outside task='tts' is a silent-no-op
|
||
# footgun; reject loudly (mirrors v0.50.0 GRPO stability policy).
|
||
if tcfg.tts_family is not None:
|
||
raise ValueError(
|
||
f"training.tts_family={tcfg.tts_family!r} requires task='tts'; "
|
||
f"got task={self.task!r}"
|
||
)
|
||
if tcfg.tts_emotion is not None:
|
||
raise ValueError(
|
||
f"training.tts_emotion={tcfg.tts_emotion!r} requires task='tts'; "
|
||
f"got task={self.task!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_classifier_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part B — classifier / reranker / cross_encoder gate.
|
||
|
||
Lazy-import policy (code-review fix): guard the import behind the
|
||
cheap str-only ``self.task`` check so the common ``task='sft'`` hot
|
||
path does not pay an import cost on every config load.
|
||
"""
|
||
tcfg = self.training
|
||
classifier_tasks = {"classifier", "reranker", "cross_encoder"}
|
||
classifier_fields_set = (
|
||
tcfg.num_labels is not None
|
||
or tcfg.classifier_kind is not None
|
||
or tcfg.label_names is not None
|
||
# v0.71.12 #146 — classifier_lora is an opt-in (default False); a
|
||
# True value outside the classifier family is a silent-no-op
|
||
# footgun, so it counts as a classifier-only field.
|
||
or bool(getattr(tcfg, "classifier_lora", False))
|
||
)
|
||
if self.task not in classifier_tasks and not classifier_fields_set:
|
||
return self
|
||
from soup_cli.utils.classifier import (
|
||
is_classifier_task,
|
||
validate_classifier_compat,
|
||
)
|
||
|
||
if is_classifier_task(self.task):
|
||
try:
|
||
validate_classifier_compat(
|
||
task=self.task,
|
||
backend=self.backend,
|
||
modality=self.modality,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
if tcfg.num_labels is None:
|
||
raise ValueError(
|
||
f"task={self.task!r} requires training.num_labels "
|
||
"(positive int <= 1024)"
|
||
)
|
||
if (
|
||
tcfg.label_names is not None
|
||
and len(tcfg.label_names) != tcfg.num_labels
|
||
):
|
||
raise ValueError(
|
||
f"len(label_names)={len(tcfg.label_names)} does not "
|
||
f"match num_labels={tcfg.num_labels}"
|
||
)
|
||
return self
|
||
# Reject classifier-only fields when task is not a classifier task.
|
||
for field in ("num_labels", "classifier_kind", "label_names"):
|
||
value = getattr(tcfg, field)
|
||
if value is not None:
|
||
raise ValueError(
|
||
f"training.{field} requires task in "
|
||
"(classifier, reranker, cross_encoder); "
|
||
f"got task={self.task!r}"
|
||
)
|
||
if getattr(tcfg, "classifier_lora", False):
|
||
raise ValueError(
|
||
"training.classifier_lora requires task in "
|
||
"(classifier, reranker, cross_encoder); "
|
||
f"got task={self.task!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_distill_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part C — ``task='distill'`` gate."""
|
||
tcfg = self.training
|
||
# v0.71.12 #145 — distill_mode defaults to "token"; a non-default
|
||
# "sequence" counts as a distill-only field (silent-no-op footgun
|
||
# rejection outside task='distill').
|
||
distill_mode_set = tcfg.distill_mode != "token"
|
||
distill_fields_set = (
|
||
tcfg.teacher_model is not None
|
||
or tcfg.distill_divergence is not None
|
||
or tcfg.distill_temperature is not None
|
||
or distill_mode_set
|
||
)
|
||
if self.task == "distill":
|
||
from soup_cli.utils.distill import validate_distill_compat
|
||
|
||
try:
|
||
validate_distill_compat(
|
||
task=self.task,
|
||
backend=self.backend,
|
||
teacher_model=tcfg.teacher_model,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
if distill_fields_set:
|
||
offenders = [
|
||
name for name, value in (
|
||
("teacher_model", tcfg.teacher_model),
|
||
("distill_divergence", tcfg.distill_divergence),
|
||
("distill_temperature", tcfg.distill_temperature),
|
||
("distill_mode", tcfg.distill_mode if distill_mode_set else None),
|
||
) if value is not None
|
||
]
|
||
raise ValueError(
|
||
f"Distillation fields {offenders} require task='distill'; "
|
||
f"got task={self.task!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_bitnet_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part D — ``quantization='bitnet_1.58'`` gate."""
|
||
if self.training.quantization != "bitnet_1.58":
|
||
return self
|
||
from soup_cli.utils.bitnet import validate_bitnet_compat
|
||
|
||
try:
|
||
validate_bitnet_compat(
|
||
task=self.task,
|
||
backend=self.backend,
|
||
modality=self.modality,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_ebft_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part E — ``ebft_variant`` requires SFT, non-MLX."""
|
||
tcfg = self.training
|
||
if tcfg.ebft_variant is None and tcfg.ebft_temperature is None:
|
||
return self
|
||
if tcfg.ebft_variant is None and tcfg.ebft_temperature is not None:
|
||
raise ValueError(
|
||
"training.ebft_temperature requires training.ebft_variant "
|
||
"to be set"
|
||
)
|
||
from soup_cli.utils.ebft_gdpo import validate_ebft_compat
|
||
|
||
try:
|
||
validate_ebft_compat(task=self.task, backend=self.backend)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_gdpo_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part E — ``gdpo_variant`` requires DPO/preference, non-MLX."""
|
||
if self.training.gdpo_variant is None:
|
||
return self
|
||
from soup_cli.utils.ebft_gdpo import validate_gdpo_compat
|
||
|
||
try:
|
||
validate_gdpo_compat(task=self.task, backend=self.backend)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_reasoning_effort_task_gate(self) -> "SoupConfig":
|
||
"""v0.52.0 Part G (code-review fix) — surface the silent-no-op
|
||
footgun when ``reasoning_effort`` / ``train_on_eot`` is set on a
|
||
task they cannot influence.
|
||
|
||
Mirrors v0.50.0 ``_validate_grpo_stability_task_gate`` policy.
|
||
``reasoning_effort`` only makes sense on SFT-family training
|
||
(sft / pretrain / distill / classifier-family) because the live
|
||
formatter (v0.52.1) will inject a system-prefix token. The other
|
||
tasks (DPO / GRPO / KTO / ORPO / SimPO / IPO / BCO / preference /
|
||
PPO / reward_model / embedding / prm / tts) do not consume it.
|
||
|
||
``train_on_eot`` is an SFT loss-mask flag; setting it on
|
||
DPO/GRPO/etc. is a silent no-op.
|
||
"""
|
||
tcfg = self.training
|
||
sft_family_tasks = {
|
||
"sft", "pretrain", "distill",
|
||
"classifier", "reranker", "cross_encoder",
|
||
}
|
||
if tcfg.reasoning_effort is not None and self.task not in sft_family_tasks:
|
||
raise ValueError(
|
||
f"training.reasoning_effort={tcfg.reasoning_effort!r} requires "
|
||
f"task in {sorted(sft_family_tasks)}; got task={self.task!r}"
|
||
)
|
||
if tcfg.train_on_eot and self.task not in sft_family_tasks:
|
||
raise ValueError(
|
||
f"training.train_on_eot=true requires task in "
|
||
f"{sorted(sft_family_tasks)}; got task={self.task!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_moe_expert_quant_compat(self) -> "SoupConfig":
|
||
"""v0.52.0 Part F — ``moe_expert_quant`` + ``train_router_only`` gates."""
|
||
tcfg = self.training
|
||
from soup_cli.utils.moe_quant import (
|
||
validate_moe_expert_quant_compat,
|
||
validate_train_router_only_compat,
|
||
)
|
||
|
||
if tcfg.moe_expert_quant is not None:
|
||
try:
|
||
validate_moe_expert_quant_compat(
|
||
backend=self.backend, moe_lora=tcfg.moe_lora,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
if tcfg.train_router_only:
|
||
try:
|
||
validate_train_router_only_compat(
|
||
backend=self.backend, moe_lora=tcfg.moe_lora,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_rollout_backend(self) -> "SoupConfig":
|
||
"""v0.50.0 Part C — ``rollout_backend`` requires task='grpo' and a
|
||
non-mlx backend. Live launcher wired in v0.71.21 (#125):
|
||
openenv requires ``rollout_func`` and ``rollout_func`` is
|
||
openenv-only (silent-no-op footgun rejection)."""
|
||
if (
|
||
self.training.rollout_func is not None
|
||
and self.training.rollout_backend != "openenv"
|
||
):
|
||
raise ValueError(
|
||
"rollout_func requires rollout_backend='openenv'; got "
|
||
f"rollout_backend={self.training.rollout_backend!r}"
|
||
)
|
||
if self.training.rollout_backend is None:
|
||
return self
|
||
if self.task != "grpo":
|
||
raise ValueError(
|
||
f"rollout_backend requires task='grpo'; got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"rollout_backend is not supported on backend=mlx in v0.50.0"
|
||
)
|
||
if (
|
||
self.training.rollout_backend == "openenv"
|
||
and self.training.rollout_func is None
|
||
):
|
||
raise ValueError(
|
||
"rollout_backend='openenv' requires training.rollout_func "
|
||
"('module.path:function_name')"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_vllm_sleep_mode(self) -> "SoupConfig":
|
||
"""v0.50.0 Part B — ``vllm_sleep_mode`` requires task='grpo' and a
|
||
vLLM-compatible backend (transformers/unsloth).
|
||
|
||
Sleep mode is a between-rollouts feature; setting it on a non-RL
|
||
task is a probable footgun and silently no-ops, so reject loudly.
|
||
"""
|
||
if not self.training.vllm_sleep_mode:
|
||
return self
|
||
if self.task != "grpo":
|
||
raise ValueError(
|
||
f"vllm_sleep_mode requires task='grpo'; got task={self.task!r}"
|
||
)
|
||
from soup_cli.utils.grpo_long_context import (
|
||
validate_vllm_sleep_mode_compat,
|
||
)
|
||
|
||
try:
|
||
validate_vllm_sleep_mode_compat(backend=self.backend)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
# ---- v0.53.0 Quant Menu II cross-validators ----------------------------
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_fp8_attention_compat(self) -> "SoupConfig":
|
||
"""v0.53.0 Part D — ``fp8_attention=True`` requires
|
||
``quantization_aware='fp8'`` and a non-mlx backend. Silent-no-op
|
||
footgun rejection (mirrors v0.32.0 spike-recovery policy).
|
||
"""
|
||
tcfg = self.training
|
||
if not tcfg.fp8_attention:
|
||
return self
|
||
from soup_cli.utils.advanced_precision import (
|
||
validate_fp8_attention_compat,
|
||
)
|
||
|
||
try:
|
||
validate_fp8_attention_compat(
|
||
fp8_attention=tcfg.fp8_attention,
|
||
quantization_aware=tcfg.quantization_aware,
|
||
backend=self.backend,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_nvfp4_compat(self) -> "SoupConfig":
|
||
"""v0.53.0 Part D — ``nvfp4=True`` requires non-mlx + text-modality.
|
||
Blackwell SM-capability check is runtime-only (live wiring v0.53.1).
|
||
"""
|
||
tcfg = self.training
|
||
if not tcfg.nvfp4:
|
||
return self
|
||
from soup_cli.utils.advanced_precision import validate_nvfp4_compat
|
||
|
||
try:
|
||
validate_nvfp4_compat(
|
||
nvfp4=tcfg.nvfp4, backend=self.backend, modality=self.modality,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_unsloth_bnb_4bit_compat(self) -> "SoupConfig":
|
||
"""v0.53.0 Part D — ``unsloth_bnb_4bit=True`` requires
|
||
``backend='unsloth'`` and ``quantization='4bit'``.
|
||
"""
|
||
tcfg = self.training
|
||
if not tcfg.unsloth_bnb_4bit:
|
||
return self
|
||
from soup_cli.utils.advanced_precision import (
|
||
validate_unsloth_bnb_4bit_compat,
|
||
)
|
||
|
||
try:
|
||
validate_unsloth_bnb_4bit_compat(
|
||
unsloth_bnb_4bit=tcfg.unsloth_bnb_4bit,
|
||
backend=self.backend,
|
||
quantization=tcfg.quantization,
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_bnb_4bit_double_quant(self) -> "SoupConfig":
|
||
"""v0.53.0 Part E — ``bnb_4bit_use_double_quant=True`` requires
|
||
``quantization='4bit'`` (silent-no-op footgun otherwise).
|
||
"""
|
||
tcfg = self.training
|
||
if not tcfg.bnb_4bit_use_double_quant:
|
||
return self
|
||
if tcfg.quantization != "4bit":
|
||
raise ValueError(
|
||
"training.bnb_4bit_use_double_quant=true requires "
|
||
f"training.quantization='4bit'; got "
|
||
f"quantization={tcfg.quantization!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_llm_int8_alias(self) -> "SoupConfig":
|
||
"""v0.53.0 Part E — ``llm_int8=True`` requires ``quantization='8bit'``.
|
||
|
||
Unlike v0.41.0 ``load_in_8bit`` (which rewrites quantization), the
|
||
``llm_int8`` flag is a pure assertion: the user explicitly says
|
||
"this is an LLM.int8 run" and we enforce the matching quantization
|
||
rather than silently rewriting it.
|
||
"""
|
||
tcfg = self.training
|
||
if not tcfg.llm_int8:
|
||
return self
|
||
if tcfg.quantization != "8bit":
|
||
raise ValueError(
|
||
"training.llm_int8=true requires training.quantization='8bit'; "
|
||
f"got quantization={tcfg.quantization!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_quantize_ref_reward(self) -> "SoupConfig":
|
||
"""v0.53.0 Part E — ``quantize_ref_model`` requires a ref-model task
|
||
and ``quantize_reward_model`` requires a reward-model task.
|
||
Silent-no-op footgun rejection.
|
||
|
||
Ref-model tasks (review fix): all preference-family trainers PLUS
|
||
``grpo`` (KL to ref policy) and ``kto`` (unpaired preference, also
|
||
keeps a frozen ref). ``ppo`` also has a ref but uses a separately
|
||
named ``policy_ref`` checkpoint; covered by the reward path too.
|
||
"""
|
||
tcfg = self.training
|
||
ref_tasks = {
|
||
"dpo", "ipo", "simpo", "orpo", "bco", "kto",
|
||
"preference", "grpo", "ppo",
|
||
}
|
||
reward_tasks = {"ppo", "reward_model"}
|
||
if tcfg.quantize_ref_model and self.task not in ref_tasks:
|
||
raise ValueError(
|
||
"training.quantize_ref_model=true requires a task with a "
|
||
"reference model "
|
||
f"(one of {sorted(ref_tasks)}); got task={self.task!r}"
|
||
)
|
||
if tcfg.quantize_reward_model and self.task not in reward_tasks:
|
||
raise ValueError(
|
||
"training.quantize_reward_model=true requires task in "
|
||
f"{sorted(reward_tasks)}; got task={self.task!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_kv_cache_type_supported(self) -> "SoupConfig":
|
||
"""v0.53.0 Part C — ``kv_cache_type`` schema gate.
|
||
|
||
Currently only ``fp8`` is gated (Hopper-only — MLX rejected). The
|
||
three remaining types (``q8_0`` / ``bf16`` / ``f16``) pass through
|
||
for every backend; v0.53.1 live wiring MAY need to narrow this
|
||
further (e.g. MLX serve may not support ``q8_0``). The schema-only
|
||
permissive policy is deliberate this release — kept here so the
|
||
v0.53.1 contributor sees the gate site immediately.
|
||
|
||
Hopper SM-capability check (compute_cap >= 9.0) is runtime-only.
|
||
"""
|
||
kv = self.training.kv_cache_type
|
||
if kv is None:
|
||
return self
|
||
if self.backend == "mlx" and kv == "fp8":
|
||
raise ValueError(
|
||
"training.kv_cache_type='fp8' is not supported on the mlx "
|
||
"backend (Hopper-only). Use kv_cache_type in {q8_0,bf16,f16} "
|
||
"or switch backend."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_relora_supported_tasks(self) -> "SoupConfig":
|
||
"""v0.40.6 (#67) — ReLoRA callback wired in every transformer-backend
|
||
trainer (sft / dpo / grpo / kto / orpo / simpo / ipo / ppo /
|
||
reward_model / pretrain / embedding / bco).
|
||
|
||
MLX backend still rejected: the callback is HF Trainer-specific.
|
||
"""
|
||
if self.training.relora_steps is None:
|
||
return self
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"relora_steps is not supported on the mlx backend "
|
||
"(callback is HF Trainer-specific). "
|
||
"Use backend='transformers' or remove relora_steps."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_quant_menu_supported_tasks(self) -> "SoupConfig":
|
||
"""v0.40.5 (#66) — Quant Menu (gptq/awq/hqq:Nbit/aqlm/eetq/mxfp4/fp8)
|
||
is wired across every transformer-backend trainer (sft / dpo / grpo /
|
||
kto / orpo / simpo / ipo / ppo / reward_model / pretrain / embedding /
|
||
bco). MLX backend still rejected (no equivalent kernels).
|
||
|
||
v0.71.19 (#81) — vision / audio modality wiring landed: the SFT
|
||
``_setup_vision_transformers`` / ``_setup_audio_transformers`` paths now
|
||
thread the unified ``build_quantization_config_for_loader``, so the
|
||
modality gate is dropped (only the mlx-backend gate remains).
|
||
"""
|
||
from soup_cli.utils.quant_menu import is_quant_menu_format
|
||
|
||
quant = self.training.quantization
|
||
# bnb 4bit / 8bit / none always apply universally — pre-existing.
|
||
if not is_quant_menu_format(quant):
|
||
return self
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
f"quantization={quant!r} is not supported on the mlx backend "
|
||
"(no equivalent kernels). Use backend='transformers' or "
|
||
"switch to quantization in {'4bit', '8bit', 'none'}."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_preference_dispatcher(self) -> "SoupConfig":
|
||
"""v0.40.0 Part B — task='preference' requires preference_loss OR
|
||
preference_loss_weights (Part D). Setting either field outside
|
||
task='preference' is rejected to keep the two config surfaces disjoint.
|
||
"""
|
||
loss = self.training.preference_loss
|
||
weights = self.training.preference_loss_weights
|
||
if self.task == "preference":
|
||
if loss is None and weights is None:
|
||
raise ValueError(
|
||
"task='preference' requires either training.preference_loss "
|
||
"(in {dpo, simpo, orpo, ipo, bco}) or "
|
||
"training.preference_loss_weights (multi-objective dict)."
|
||
)
|
||
return self
|
||
if loss is not None:
|
||
raise ValueError(
|
||
f"training.preference_loss={loss!r} is only meaningful for "
|
||
f"task='preference'; got task={self.task!r}. Either set "
|
||
"task='preference' or remove preference_loss."
|
||
)
|
||
if weights is not None:
|
||
raise ValueError(
|
||
"training.preference_loss_weights is only meaningful for "
|
||
f"task='preference'; got task={self.task!r}. Either set "
|
||
"task='preference' or remove preference_loss_weights."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_dpo_variants_supported_tasks(self) -> "SoupConfig":
|
||
"""v0.40.0 Part C — β-schedule + ref-model regen are DPO-family only.
|
||
|
||
Allowed: task in {dpo, ipo} OR (task='preference' AND
|
||
preference_loss in {dpo, ipo}). Rejected on mlx backend.
|
||
"""
|
||
tcfg = self.training
|
||
sched = tcfg.dpo_beta_schedule
|
||
end = tcfg.dpo_beta_end
|
||
regen = tcfg.dpo_ref_regen_epochs
|
||
if sched is None and end is None and regen is None:
|
||
return self
|
||
# End/schedule mutual requirement.
|
||
if sched is not None and end is None:
|
||
raise ValueError(
|
||
"dpo_beta_schedule requires dpo_beta_end (the target β at "
|
||
"end of training). Set dpo_beta_end or remove dpo_beta_schedule."
|
||
)
|
||
if end is not None and sched is None:
|
||
raise ValueError(
|
||
"dpo_beta_end requires dpo_beta_schedule. Set "
|
||
"dpo_beta_schedule in {linear, cosine, exponential} or "
|
||
"remove dpo_beta_end."
|
||
)
|
||
# Backend gate.
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"DPO variants (dpo_beta_schedule / dpo_ref_regen_epochs) are "
|
||
"not supported on the mlx backend in v0.40.0 (TRL trainer "
|
||
"internals required). Use backend='transformers'."
|
||
)
|
||
# Task gate — DPO family only.
|
||
family_ok = self.task in ("dpo", "ipo") or (
|
||
self.task == "preference"
|
||
and tcfg.preference_loss in ("dpo", "ipo")
|
||
)
|
||
if not family_ok:
|
||
raise ValueError(
|
||
f"DPO variants (dpo_beta_schedule / dpo_ref_regen_epochs) "
|
||
f"require task in {{dpo, ipo}} or task='preference' with "
|
||
f"preference_loss in {{dpo, ipo}}; got task={self.task!r}, "
|
||
f"preference_loss={tcfg.preference_loss!r}."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_preference_loss_weights(self) -> "SoupConfig":
|
||
"""v0.40.0 Part D — multi-objective preference_loss_weights gate.
|
||
|
||
Allowed: task='preference' only. Mutually exclusive with the scalar
|
||
preference_loss. Validates value bounds + sum-to-1 + key allowlist.
|
||
"""
|
||
tcfg = self.training
|
||
weights = tcfg.preference_loss_weights
|
||
if weights is None:
|
||
return self
|
||
if not isinstance(weights, dict):
|
||
raise ValueError(
|
||
"preference_loss_weights must be a dict, e.g. "
|
||
"{'dpo': 0.7, 'bco': 0.3}."
|
||
)
|
||
if self.task != "preference":
|
||
raise ValueError(
|
||
"preference_loss_weights requires task='preference'; got "
|
||
f"task={self.task!r}."
|
||
)
|
||
if tcfg.preference_loss is not None:
|
||
raise ValueError(
|
||
"preference_loss_weights and (scalar) preference_loss are "
|
||
"mutually exclusive — pick one."
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"preference_loss_weights is not supported on the mlx backend "
|
||
"in v0.40.0. Use backend='transformers'."
|
||
)
|
||
if not (2 <= len(weights) <= 5):
|
||
raise ValueError(
|
||
f"preference_loss_weights must have between 2 and 5 entries "
|
||
"(single-entry blends are equivalent to the scalar "
|
||
"preference_loss field; use that instead); got "
|
||
f"{len(weights)}."
|
||
)
|
||
allowed = {"dpo", "simpo", "orpo", "ipo", "bco"}
|
||
for key in weights:
|
||
if not isinstance(key, str):
|
||
raise ValueError(
|
||
f"preference_loss_weights keys must be strings; "
|
||
f"got {type(key).__name__}."
|
||
)
|
||
if "\x00" in key:
|
||
raise ValueError(
|
||
"preference_loss_weights keys cannot contain null bytes."
|
||
)
|
||
unknown = set(weights.keys()) - allowed
|
||
if unknown:
|
||
raise ValueError(
|
||
f"preference_loss_weights keys must be in {sorted(allowed)}; "
|
||
f"unknown: {sorted(unknown)}."
|
||
)
|
||
for key, value in weights.items():
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
raise ValueError(
|
||
f"preference_loss_weights[{key!r}] must be a number; "
|
||
f"got {type(value).__name__}."
|
||
)
|
||
if not (0 < float(value) <= 1):
|
||
raise ValueError(
|
||
f"preference_loss_weights[{key!r}]={value!r} must be in (0, 1]."
|
||
)
|
||
total = sum(float(v) for v in weights.values())
|
||
if abs(total - 1.0) > 1e-6:
|
||
raise ValueError(
|
||
f"preference_loss_weights must sum to 1.0 (±1e-6); got {total!r}."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_unlearn_compat(self) -> "SoupConfig":
|
||
"""v0.61.0 Part A — ``task='unlearn'`` cross-validator.
|
||
|
||
Enforces:
|
||
- ``unlearn_method`` is set when ``task='unlearn'``.
|
||
- ``unlearn_method`` is rejected on any other task (silent no-op
|
||
footgun — mirrors v0.52.0 distill / classifier task-gate).
|
||
- ``data.forget_set`` is present when ``task='unlearn'``.
|
||
- Backend != mlx (live wiring deferred to v0.61.1).
|
||
"""
|
||
tcfg = self.training
|
||
method = tcfg.unlearn_method
|
||
|
||
# method-set-outside-unlearn rejection (silent-no-op footgun).
|
||
if method is not None and self.task != "unlearn":
|
||
raise ValueError(
|
||
f"training.unlearn_method={method!r} requires task='unlearn'; "
|
||
f"got task={self.task!r}. Remove unlearn_method or set "
|
||
f"task='unlearn'."
|
||
)
|
||
|
||
# unlearn_alpha-without-method rejection.
|
||
if tcfg.unlearn_alpha is not None and method is None:
|
||
raise ValueError(
|
||
"training.unlearn_alpha requires training.unlearn_method "
|
||
"to be set."
|
||
)
|
||
|
||
if self.task != "unlearn":
|
||
return self
|
||
|
||
# task='unlearn' requires the method.
|
||
if method is None:
|
||
raise ValueError(
|
||
"task='unlearn' requires training.unlearn_method in "
|
||
"{npo, simnpo, rmu}."
|
||
)
|
||
|
||
# task='unlearn' requires the forget_set.
|
||
if not self.data.forget_set:
|
||
raise ValueError(
|
||
"task='unlearn' requires data.forget_set (path or HF "
|
||
"dataset id pointing at rows to unlearn)."
|
||
)
|
||
|
||
# Delegate backend gate to the pure helper so the runtime path
|
||
# and schema-load path stay consistent.
|
||
from soup_cli.utils.unlearning import validate_unlearn_compat
|
||
|
||
try:
|
||
validate_unlearn_compat(task=self.task, backend=self.backend)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_grace_codebook_compat(self) -> "SoupConfig":
|
||
"""v0.62.0 Part E — GRACE codebook cross-validator.
|
||
|
||
Rules:
|
||
* ``grace_codebook=True`` requires BOTH ``grace_codebook_size`` and
|
||
``grace_codebook_dim`` to be set (no codebook can be allocated
|
||
without both knobs).
|
||
* Setting ``grace_codebook_size`` / ``grace_codebook_dim`` without
|
||
``grace_codebook=True`` is a silent-no-op footgun — rejected.
|
||
"""
|
||
tcfg = self.training
|
||
flag = tcfg.grace_codebook
|
||
size = tcfg.grace_codebook_size
|
||
dim = tcfg.grace_codebook_dim
|
||
|
||
if not flag and (size is not None or dim is not None):
|
||
raise ValueError(
|
||
"training.grace_codebook_size / grace_codebook_dim require "
|
||
"training.grace_codebook=true."
|
||
)
|
||
if flag and (size is None or dim is None):
|
||
raise ValueError(
|
||
"training.grace_codebook=true requires BOTH "
|
||
"training.grace_codebook_size and training.grace_codebook_dim."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_citation_faithful_compat(self) -> "SoupConfig":
|
||
"""v0.62.0 Part D — citation-faithful FT cross-validator.
|
||
|
||
Rules:
|
||
* ``citation_faithful=True`` requires ``data.format='raft'`` (the
|
||
RAFT row carries the doc references; other formats can't supply
|
||
ground-truth citation IDs).
|
||
* ``citation_faithful=True`` requires ``task in {sft, pretrain}``
|
||
(the span-mask runtime that v0.62.1 will ship only makes sense
|
||
for the SFT family; mirrors v0.52.0 distill / classifier
|
||
task-gate policy — review M3 fix).
|
||
* ``citation_style`` set without ``citation_faithful=True`` is a
|
||
silent-no-op footgun — rejected (mirrors v0.61.0 unlearn_alpha /
|
||
v0.62.0 Part B ra_dit_retriever_model policy).
|
||
* Same rejection for ``citation_recall_threshold`` without the flag.
|
||
"""
|
||
tcfg = self.training
|
||
|
||
if tcfg.citation_style is not None and not tcfg.citation_faithful:
|
||
raise ValueError(
|
||
"training.citation_style requires "
|
||
"training.citation_faithful=true."
|
||
)
|
||
if (
|
||
tcfg.citation_recall_threshold is not None
|
||
and not tcfg.citation_faithful
|
||
):
|
||
raise ValueError(
|
||
"training.citation_recall_threshold requires "
|
||
"training.citation_faithful=true."
|
||
)
|
||
if tcfg.citation_faithful:
|
||
if self.data.format != "raft":
|
||
raise ValueError(
|
||
"training.citation_faithful=true requires "
|
||
f"data.format='raft'; got data.format={self.data.format!r}. "
|
||
"Citation-faithful FT pairs with the v0.62.0 Part A "
|
||
"RAFT data format (which carries the doc references)."
|
||
)
|
||
if self.task not in ("sft", "pretrain"):
|
||
raise ValueError(
|
||
"training.citation_faithful=true requires "
|
||
f"task in {{sft, pretrain}}; got task={self.task!r}. "
|
||
"Citation-faithful FT is an SFT-family feature; the "
|
||
"live span-mask runtime ships in v0.62.1."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_ra_dit_compat(self) -> "SoupConfig":
|
||
"""v0.62.0 Part B — RA-DIT stage / task pairing.
|
||
|
||
Each stage requires the matching base task:
|
||
|
||
* ``retriever`` -> ``task='embedding'``
|
||
* ``generator`` -> ``task='sft'``
|
||
|
||
Also rejects ``ra_dit_retriever_model`` set without ``ra_dit_stage``
|
||
(silent no-op footgun — mirrors v0.61.0 ``unlearn_alpha`` policy).
|
||
"""
|
||
tcfg = self.training
|
||
stage = tcfg.ra_dit_stage
|
||
|
||
if tcfg.ra_dit_retriever_model is not None and stage is None:
|
||
raise ValueError(
|
||
"training.ra_dit_retriever_model requires "
|
||
"training.ra_dit_stage to be set ('retriever' or 'generator')."
|
||
)
|
||
|
||
if stage is None:
|
||
return self
|
||
|
||
from soup_cli.utils.ra_dit import validate_ra_dit_compat
|
||
|
||
try:
|
||
validate_ra_dit_compat(stage=stage, task=self.task)
|
||
except ValueError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_mole_routing_compat(self) -> "SoupConfig":
|
||
"""v0.67.0 Part C / v0.71.12 #222 — MoLE per-token routing gate.
|
||
|
||
Rules:
|
||
* MoLE fields (``mole_task_adapters`` / ``mole_top_k`` /
|
||
``mole_temperature``) set outside ``task='moe_lora_routing'`` are
|
||
rejected — silent-no-op footgun (mirrors v0.52.0 distill / v0.62.0
|
||
citation task-gates).
|
||
* ``task='moe_lora_routing'`` rejects ``backend='mlx'`` (the live gate
|
||
needs torch dispatch).
|
||
* ``task='moe_lora_routing'`` requires ``mole_task_adapters`` (2-64
|
||
deduplicated paths — validated by the field validator).
|
||
* ``mole_top_k`` must not exceed ``len(mole_task_adapters)``.
|
||
"""
|
||
tcfg = self.training
|
||
mole_fields_set = (
|
||
tcfg.mole_task_adapters is not None
|
||
or tcfg.mole_top_k is not None
|
||
or tcfg.mole_temperature is not None
|
||
)
|
||
|
||
if self.task != "moe_lora_routing":
|
||
if mole_fields_set:
|
||
offenders = [
|
||
name
|
||
for name, val in (
|
||
("mole_task_adapters", tcfg.mole_task_adapters),
|
||
("mole_top_k", tcfg.mole_top_k),
|
||
("mole_temperature", tcfg.mole_temperature),
|
||
)
|
||
if val is not None
|
||
]
|
||
raise ValueError(
|
||
f"MoLE field(s) {offenders} require task='moe_lora_routing' "
|
||
f"(got task={self.task!r})."
|
||
)
|
||
return self
|
||
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"MoLE routing (task='moe_lora_routing') is not supported on "
|
||
"the mlx backend (the gating kernel needs torch dispatch)."
|
||
)
|
||
if tcfg.mole_task_adapters is None:
|
||
raise ValueError(
|
||
"task='moe_lora_routing' requires training.mole_task_adapters "
|
||
"(2-64 pre-trained task-LoRA paths to route over)."
|
||
)
|
||
if (
|
||
tcfg.mole_top_k is not None
|
||
and tcfg.mole_top_k > len(tcfg.mole_task_adapters)
|
||
):
|
||
raise ValueError(
|
||
f"mole_top_k={tcfg.mole_top_k} exceeds "
|
||
f"len(mole_task_adapters)={len(tcfg.mole_task_adapters)}."
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_mlx_task_support(self) -> "SoupConfig":
|
||
"""MLX backend only supports sft, dpo, and grpo tasks (v0.25.0).
|
||
|
||
DPO and GRPO wrappers are scaffolding in v0.25.0 — they raise
|
||
NotImplementedError at ``train()`` time because upstream mlx-lm has
|
||
not yet shipped DPO/GRPO training helpers. Users who pick them will
|
||
instead see this friendly error at config-load time.
|
||
"""
|
||
if self.backend != "mlx":
|
||
return self
|
||
if self.task == "sft":
|
||
return self
|
||
raise ValueError(
|
||
f"MLX backend only ships SFT in v0.25.0; task='{self.task}' "
|
||
"is not yet implemented (upstream mlx-lm does not expose a "
|
||
f"training helper). Use backend=transformers for task={self.task}."
|
||
)
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_uld_compat(self) -> "SoupConfig":
|
||
"""v0.70.0 Part B — Universal Logit Distillation gate.
|
||
|
||
``uld_strategy`` is only meaningful when ``task='distill'`` —
|
||
cross-tokenizer distillation has no analogue outside the
|
||
distillation trainer. Rejected on other tasks with a friendly
|
||
message, and on MLX backend with a distinct message.
|
||
|
||
Composes with v0.52 distillation task: when set, the
|
||
:class:`uld.ULDConfig` validation fires (top_k cross-validation,
|
||
vocab-size bounds) at config-load.
|
||
"""
|
||
tcfg = self.training
|
||
strategy = tcfg.uld_strategy
|
||
top_k = tcfg.uld_top_k
|
||
if strategy is None and top_k is None:
|
||
return self
|
||
if strategy is None:
|
||
# top_k without strategy is a silent no-op footgun.
|
||
raise ValueError(
|
||
"uld_top_k requires uld_strategy to be set"
|
||
)
|
||
if self.task != "distill":
|
||
raise ValueError(
|
||
"uld_strategy / uld_top_k are only valid when "
|
||
f"task='distill'; got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"uld_strategy is not supported on backend=mlx in v0.70.0 "
|
||
"(cross-tokenizer distillation is transformers-only)"
|
||
)
|
||
# Cross-check: topk_align requires top_k.
|
||
if strategy == "topk_align" and top_k is None:
|
||
raise ValueError(
|
||
"uld_strategy='topk_align' requires uld_top_k to be set"
|
||
)
|
||
if strategy != "topk_align" and top_k is not None:
|
||
raise ValueError(
|
||
"uld_top_k is only valid when uld_strategy='topk_align'; "
|
||
f"got uld_strategy={strategy!r}"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_echo_trap_compat(self) -> "SoupConfig":
|
||
"""v0.70.0 Part F — echo-trap detector task gate.
|
||
|
||
``echo_trap_enabled`` (and ``echo_trap_halt``) only meaningful
|
||
on RL tasks (grpo / ppo). Setting ``echo_trap_halt`` without
|
||
``echo_trap_enabled`` is a silent no-op footgun — reject.
|
||
"""
|
||
tcfg = self.training
|
||
if (
|
||
not tcfg.echo_trap_enabled
|
||
and not tcfg.echo_trap_halt
|
||
and not tcfg.echo_trap_tokenizer_aware
|
||
):
|
||
return self
|
||
if not tcfg.echo_trap_enabled and (
|
||
tcfg.echo_trap_halt or tcfg.echo_trap_tokenizer_aware
|
||
):
|
||
raise ValueError(
|
||
"echo_trap_halt / echo_trap_tokenizer_aware require "
|
||
"echo_trap_enabled=True"
|
||
)
|
||
if self.task not in ("grpo", "ppo"):
|
||
raise ValueError(
|
||
"echo_trap_enabled / echo_trap_halt / "
|
||
"echo_trap_tokenizer_aware are only valid on "
|
||
f"task in {{'grpo', 'ppo'}}; got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"echo_trap_enabled is not supported on backend=mlx in "
|
||
"v0.70.0"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_rl_checkpoint_compat(self) -> "SoupConfig":
|
||
"""v0.70.0 Part D — mid-epoch RL checkpoint task gate.
|
||
|
||
``rl_checkpoint_save_every_steps`` is only meaningful on RL
|
||
tasks (grpo / ppo). Non-RL tasks already have HF Trainer's
|
||
per-epoch checkpointing. Rejected on other tasks with a
|
||
friendly message.
|
||
"""
|
||
tcfg = self.training
|
||
if tcfg.rl_checkpoint_save_every_steps is None:
|
||
return self
|
||
if self.task not in ("grpo", "ppo"):
|
||
raise ValueError(
|
||
"rl_checkpoint_save_every_steps is only valid on RL tasks "
|
||
f"(grpo / ppo); got task={self.task!r}. Non-RL tasks use "
|
||
"HF Trainer's per-epoch checkpointing already."
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"rl_checkpoint_save_every_steps is not supported on "
|
||
"backend=mlx in v0.70.0"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_minillm_compat(self) -> "SoupConfig":
|
||
"""v0.70.0 Part C — MiniLLM compatibility gate.
|
||
|
||
``minillm_enabled`` requires ``task='distill'`` on a non-mlx
|
||
backend. Setting any minillm_* tunable without
|
||
``minillm_enabled=True`` is rejected (silent no-op footgun
|
||
mirroring v0.52 distill / v0.62 grace_codebook policy).
|
||
"""
|
||
tcfg = self.training
|
||
any_field_set = (
|
||
tcfg.minillm_teacher_mix_ratio != 0.0
|
||
or tcfg.minillm_length_normalize is not True
|
||
or tcfg.minillm_pretrain_anchor_weight != 0.0
|
||
or tcfg.minillm_pretrain_anchor_path is not None
|
||
or tcfg.minillm_on_policy is True
|
||
or tcfg.minillm_rollout_length is not None
|
||
)
|
||
if not tcfg.minillm_enabled and not any_field_set:
|
||
return self
|
||
if not tcfg.minillm_enabled and any_field_set:
|
||
offenders = []
|
||
if tcfg.minillm_teacher_mix_ratio != 0.0:
|
||
offenders.append("minillm_teacher_mix_ratio")
|
||
if tcfg.minillm_length_normalize is not True:
|
||
offenders.append("minillm_length_normalize")
|
||
if tcfg.minillm_pretrain_anchor_weight != 0.0:
|
||
offenders.append("minillm_pretrain_anchor_weight")
|
||
if tcfg.minillm_pretrain_anchor_path is not None:
|
||
offenders.append("minillm_pretrain_anchor_path")
|
||
if tcfg.minillm_on_policy is True:
|
||
offenders.append("minillm_on_policy")
|
||
if tcfg.minillm_rollout_length is not None:
|
||
offenders.append("minillm_rollout_length")
|
||
raise ValueError(
|
||
f"MiniLLM tunables {offenders} require minillm_enabled=True"
|
||
)
|
||
if self.task != "distill":
|
||
raise ValueError(
|
||
"minillm_enabled requires task='distill'; "
|
||
f"got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"minillm_enabled is not supported on backend=mlx in v0.70.0"
|
||
)
|
||
# Cross-check: anchor weight + path mutual requirements.
|
||
if (
|
||
tcfg.minillm_pretrain_anchor_weight > 0.0
|
||
and tcfg.minillm_pretrain_anchor_path is None
|
||
):
|
||
raise ValueError(
|
||
"minillm_pretrain_anchor_weight > 0 requires "
|
||
"minillm_pretrain_anchor_path to be set"
|
||
)
|
||
if (
|
||
tcfg.minillm_pretrain_anchor_weight == 0.0
|
||
and tcfg.minillm_pretrain_anchor_path is not None
|
||
):
|
||
raise ValueError(
|
||
"minillm_pretrain_anchor_path is set but "
|
||
"minillm_pretrain_anchor_weight is 0 (silent no-op)"
|
||
)
|
||
# v0.71.18 #257 — rollout_length only applies to the on-policy path.
|
||
if tcfg.minillm_rollout_length is not None and not tcfg.minillm_on_policy:
|
||
raise ValueError(
|
||
"minillm_rollout_length requires minillm_on_policy=True "
|
||
"(unused by the offline distribution blend)"
|
||
)
|
||
return self
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_reward_hack_compat(self) -> "SoupConfig":
|
||
"""v0.70.0 Part A — reward-hacking detector task / backend gate.
|
||
|
||
``reward_hack_detector`` + ``reward_hack_halt`` are only meaningful
|
||
for RL tasks (grpo / ppo). Rejected outside those tasks with a
|
||
friendly message that names the offending fields. MLX backend
|
||
rejected with a distinct message (matches v0.34.0 / v0.50.0
|
||
review-fix policy of distinct error reasons).
|
||
"""
|
||
tcfg = self.training
|
||
detector = tcfg.reward_hack_detector
|
||
halt = tcfg.reward_hack_halt
|
||
if detector is None and not halt:
|
||
return self
|
||
# halt without detector is a silent no-op footgun — reject.
|
||
if detector is None and halt:
|
||
raise ValueError(
|
||
"reward_hack_halt=True requires reward_hack_detector to be set"
|
||
)
|
||
if self.task not in ("grpo", "ppo"):
|
||
raise ValueError(
|
||
"reward_hack_detector / reward_hack_halt are only valid on "
|
||
f"task in {{'grpo', 'ppo'}}; got task={self.task!r}"
|
||
)
|
||
if self.backend == "mlx":
|
||
raise ValueError(
|
||
"reward_hack_detector is not supported on backend=mlx in "
|
||
"v0.70.0 (RL detectors are transformers-only)"
|
||
)
|
||
return self
|
||
|
||
|
||
# --- Built-in templates ---
|
||
|
||
# DEPRECATED (v0.39.0 Part E) — these inline templates are kept for back-compat.
|
||
# The canonical source is `soup_cli/templates/*.yaml` with `manifest.json`.
|
||
# Both sources are asserted equal in tests/test_templates_yaml.py — when editing
|
||
# a template, update both. Planned removal: v0.41.0+ once external consumers
|
||
# have migrated to the YAML registry.
|
||
TEMPLATES: dict[str, str] = {
|
||
"chat": """# Soup template: Chat Assistant
|
||
# Fine-tune a model for conversational chat
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: sft
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/train.jsonl
|
||
format: alpaca
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 2e-5
|
||
batch_size: auto
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output
|
||
""",
|
||
"code": """# Soup template: Code Model
|
||
# Fine-tune a model for code generation / completion
|
||
|
||
base: codellama/CodeLlama-7b-Instruct-hf
|
||
task: sft
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/code_train.jsonl
|
||
format: alpaca
|
||
val_split: 0.1
|
||
max_length: 4096
|
||
|
||
training:
|
||
epochs: 2
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
lora:
|
||
r: 128
|
||
alpha: 32
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output
|
||
""",
|
||
"reasoning": """# Soup template: Reasoning / GRPO
|
||
# Fine-tune a model for chain-of-thought reasoning with GRPO
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: grpo
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/reasoning_train.jsonl
|
||
format: sharegpt
|
||
val_split: 0.1
|
||
max_length: 4096
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 8
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
grpo_beta: 0.1
|
||
num_generations: 4
|
||
reward_fn: accuracy
|
||
|
||
output: ./output
|
||
""",
|
||
"vision": """# Soup template: Vision / Multimodal
|
||
# Fine-tune a vision-language model for image understanding
|
||
|
||
base: meta-llama/Llama-3.2-11B-Vision-Instruct
|
||
task: sft
|
||
modality: vision
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/vision_train.jsonl
|
||
format: llava
|
||
image_dir: ./data/images
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output
|
||
""",
|
||
"medical": """# Soup template: Medical / Domain Expert
|
||
# Fine-tune a model with domain-specific knowledge
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: sft
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/medical_train.jsonl
|
||
format: alpaca
|
||
val_split: 0.15
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 5
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 8
|
||
lora:
|
||
r: 128
|
||
alpha: 32
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output
|
||
""",
|
||
"kto": """# Soup template: KTO (Kahneman-Tversky Optimization)
|
||
# Align a model using unpaired preference data (no need for chosen+rejected pairs)
|
||
#
|
||
# Data format (JSONL):
|
||
# {"prompt": "What is 2+2?", "completion": "4", "label": true}
|
||
# {"prompt": "What is 2+2?", "completion": "Fish", "label": false}
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: kto
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/kto_train.jsonl
|
||
format: kto
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
kto_beta: 0.1
|
||
|
||
output: ./output
|
||
""",
|
||
"orpo": """# Soup template: ORPO (Odds Ratio Preference Optimization)
|
||
# Align a model without a reference model — simpler than DPO
|
||
#
|
||
# Data format (JSONL):
|
||
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: orpo
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/preference_train.jsonl
|
||
format: dpo
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
orpo_beta: 0.1
|
||
|
||
output: ./output
|
||
""",
|
||
"bco": """# Soup template: BCO (Binary Classifier Optimization)
|
||
# Preference alignment via binary classification of chosen vs rejected.
|
||
#
|
||
# Data format (JSONL) — same as DPO:
|
||
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: bco
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/preference_train.jsonl
|
||
format: dpo
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
bco_beta: 0.1
|
||
|
||
output: ./output
|
||
""",
|
||
"simpo": """# Soup template: SimPO (Simple Preference Optimization)
|
||
# Reference-free preference alignment with length-normalized rewards
|
||
#
|
||
# Data format (JSONL):
|
||
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: simpo
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/preference_train.jsonl
|
||
format: dpo
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
simpo_gamma: 0.5
|
||
cpo_alpha: 1.0
|
||
|
||
output: ./output
|
||
""",
|
||
"ipo": """# Soup template: IPO (Identity Preference Optimization)
|
||
# A theoretically grounded variant of DPO with stronger regularization
|
||
#
|
||
# Data format (JSONL):
|
||
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: ipo
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/preference_train.jsonl
|
||
format: dpo
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
ipo_tau: 0.1
|
||
|
||
output: ./output
|
||
""",
|
||
"pretrain": """# Soup template: Continued Pre-training
|
||
# Continue pre-training a model on raw text data (domain adaptation)
|
||
#
|
||
# Data format (JSONL):
|
||
# {"text": "Your raw text document here..."}
|
||
#
|
||
# Or plain .txt files (one document per line or entire file as one document).
|
||
|
||
base: meta-llama/Llama-3.1-8B
|
||
task: pretrain
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/corpus.jsonl
|
||
format: plaintext
|
||
val_split: 0.05
|
||
max_length: 4096
|
||
|
||
training:
|
||
epochs: 1
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 8
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output_pretrain
|
||
""",
|
||
"moe": """# Soup template: MoE (Mixture of Experts) Fine-tuning
|
||
# Fine-tune a Mixture of Experts model with ScatterMoE LoRA
|
||
#
|
||
# Supported MoE models: Qwen3-30B-A3B, Mixtral-8x7B, DeepSeek-V3, etc.
|
||
|
||
base: Qwen/Qwen3-30B-A3B
|
||
task: sft
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/train.jsonl
|
||
format: alpaca
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 8
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
moe_lora: true
|
||
moe_aux_loss_coeff: 0.01
|
||
|
||
output: ./output
|
||
""",
|
||
"longcontext": """# Soup template: Long-Context Fine-tuning (128k+)
|
||
# Extend model context window for long-document understanding
|
||
#
|
||
# Uses RoPE scaling + gradient checkpointing + FlashAttention for 128k tokens.
|
||
# Optionally enable Liger Kernel for additional memory savings.
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: sft
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/long_context_train.jsonl
|
||
format: alpaca
|
||
val_split: 0.05
|
||
max_length: 131072
|
||
|
||
training:
|
||
epochs: 1
|
||
lr: 5e-6
|
||
batch_size: 1
|
||
gradient_accumulation_steps: 16
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
gradient_checkpointing: true
|
||
rope_scaling_type: dynamic
|
||
use_flash_attn: true
|
||
# use_liger: true # pip install 'soup-cli[liger]' for fused ops
|
||
# use_ring_attention: true # Multi-GPU sequence parallelism
|
||
|
||
output: ./output_longctx
|
||
""",
|
||
"embedding": """# Soup template: Embedding Model Fine-tuning
|
||
# Fine-tune a sentence embedding model (BGE, E5, GTE, etc.)
|
||
#
|
||
# Data format (JSONL) — contrastive pairs:
|
||
# {"anchor": "What is Python?", "positive": "Python is a programming language."}
|
||
#
|
||
# Data format (JSONL) — triplets:
|
||
# {"anchor": "query", "positive": "relevant doc", "negative": "unrelated doc"}
|
||
|
||
base: BAAI/bge-base-en-v1.5
|
||
task: embedding
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/embedding_train.jsonl
|
||
format: embedding
|
||
val_split: 0.1
|
||
max_length: 512
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 2e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: none
|
||
embedding_loss: contrastive
|
||
embedding_margin: 0.5
|
||
embedding_pooling: mean
|
||
|
||
output: ./output_embedding
|
||
""",
|
||
"audio": """# Soup template: Audio / Speech
|
||
# Fine-tune an audio-language model for speech understanding
|
||
#
|
||
# Supported models: Qwen2-Audio, Whisper (via transformers)
|
||
#
|
||
# Data format (JSONL):
|
||
# {"audio": "path/to/audio.wav", "messages": [
|
||
# {"role": "user", "content": "Transcribe."},
|
||
# {"role": "assistant", "content": "Hello world."}]}
|
||
|
||
base: Qwen/Qwen2-Audio-7B-Instruct
|
||
task: sft
|
||
modality: audio
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/audio_train.jsonl
|
||
format: audio
|
||
audio_dir: ./data/audio
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 1e-5
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 8
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output_audio
|
||
""",
|
||
"tool-calling": """# Soup template: Tool-Calling / Agentic Fine-tuning
|
||
# Fine-tune a model to call tools / functions correctly
|
||
#
|
||
# Data format (JSONL):
|
||
# {
|
||
# "messages": [{"role": "user", "content": "What's the weather in Tokyo?"}],
|
||
# "tools": [{"type": "function", "function": {
|
||
# "name": "get_weather",
|
||
# "description": "Get current weather for a city",
|
||
# "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
|
||
# }}],
|
||
# "tool_calls": [{"function": {
|
||
# "name": "get_weather",
|
||
# "arguments": "{\\"city\\": \\"Tokyo\\"}"
|
||
# }}]
|
||
# }
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: sft
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/tool_calling_train.jsonl
|
||
format: tool-calling
|
||
val_split: 0.1
|
||
max_length: 4096
|
||
|
||
training:
|
||
epochs: 3
|
||
lr: 2e-4
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 16
|
||
alpha: 32
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
|
||
output: ./output
|
||
""",
|
||
"rlhf": """# Soup template: Full RLHF Pipeline (SFT + Reward Model + PPO)
|
||
# Three-stage training: 1) SFT warmup, 2) Reward model, 3) PPO alignment
|
||
#
|
||
# Usage:
|
||
# Step 1: soup train --config soup_sft.yaml # SFT warmup
|
||
# Step 2: soup train --config soup_rm.yaml # Train reward model
|
||
# Step 3: soup train --config soup_ppo.yaml # PPO with reward model
|
||
#
|
||
# This template generates the PPO config (step 3).
|
||
# For steps 1-2, use: soup init --template chat (SFT) and edit task to reward_model.
|
||
|
||
base: meta-llama/Llama-3.1-8B-Instruct
|
||
task: ppo
|
||
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
|
||
|
||
data:
|
||
train: ./data/prompts.jsonl
|
||
format: chatml
|
||
val_split: 0.1
|
||
max_length: 2048
|
||
|
||
training:
|
||
epochs: 1
|
||
lr: 1e-6
|
||
batch_size: auto
|
||
gradient_accumulation_steps: 4
|
||
lora:
|
||
r: 64
|
||
alpha: 16
|
||
target_modules: auto
|
||
quantization: 4bit
|
||
reward_model: ./output_rm
|
||
ppo_epochs: 4
|
||
ppo_clip_ratio: 0.2
|
||
ppo_kl_penalty: 0.05
|
||
|
||
output: ./output_ppo
|
||
""",
|
||
}
|