feat(recipes): v0.31.0 — Model & Recipe Breadth

Expand the recipe catalog from 46 to 80 entries — every popular open-weight
model family now has a validated Soup recipe.

Part A — Vision (6 recipes): Llama-3.2-Vision-90B, Pixtral-12B, Qwen2-VL
(7B + 72B), InternVL 2.5, MiniCPM-V 2.6
Part B — Audio (3 recipes): Qwen2-Audio, SeamlessM4T v2, Whisper-large-v3
Part C — Reasoning (7 recipes): completes the 6 DeepSeek-R1-Distill sizes,
plus Qwen3-Coder, Qwen3-30B-A3B reasoning, Phi-4 reasoning
Part D — Edge (8 recipes): SmolLM2 (135M / 360M / 1.7B), Qwen2.5
(0.5B / 1.5B / 3B), Gemma 2 2B, Phi-3.5-mini
Part E — Domain (8 recipes): BioMistral, Meditron, CodeLlama (13B / 70B),
Magicoder, Mathstral, Nemotron-4 340B, Llama-2-13b-finance
Part F — Multimodal reasoning (2 recipes): Llama-3.2-Vision GRPO, Pixtral DPO
Part G — Recipe-validation CI workflow on every PR touching recipe / config /
data code (.github/workflows/recipe-validation.yml)
Part H — 750 parametrized tests covering catalog-wide invariants:
  model-id safety (no `..`/`://`/null bytes), lora.target_modules non-empty,
  max_length within schema bounds, GRPO recipes wire reward_fn +
  num_generations >= 2, vision recipes set image_dir, audio recipes set
  audio_dir, default data path is non-empty + relative

Live 100-step per-recipe smoke train (requires GPU runner) deferred to v0.31.1.

Tests: 2886 → 3607 (+721). Catalog: 46 → 80 (target met).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-04-25 19:03:23 +05:00
parent 6c8f12a1f3
commit d0b7719858
9 changed files with 1618 additions and 22 deletions

50
.github/workflows/recipe-validation.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Recipe Validation
# v0.31.0 — runs the per-recipe validation suite on every PR + push to main.
#
# Each recipe in soup_cli/recipes/catalog.py is parsed, loaded as a
# SoupConfig, and asserted to match its RecipeMeta declaration. Catches
# regressions when an upstream model id is renamed or removed, or when the
# config schema changes in a backward-incompatible way.
#
# A 100-step live smoke train per recipe is on the v0.31.1 roadmap and will
# require a self-hosted GPU runner; this workflow is the CPU-only validation
# tier that runs on every PR.
on:
push:
branches: [main]
paths:
- 'soup_cli/recipes/**'
- 'soup_cli/config/**'
- 'soup_cli/data/formats.py'
- 'soup_cli/data/loader.py'
- 'tests/test_recipes_v031.py'
- 'tests/test_recipes.py'
- '.github/workflows/recipe-validation.yml'
pull_request:
branches: [main]
paths:
- 'soup_cli/recipes/**'
- 'soup_cli/config/**'
- 'soup_cli/data/formats.py'
- 'soup_cli/data/loader.py'
- 'tests/test_recipes_v031.py'
- 'tests/test_recipes.py'
- '.github/workflows/recipe-validation.yml'
jobs:
validate-recipes:
runs-on: ubuntu-latest
env:
PYTHONUTF8: "1"
PYTHONIOENCODING: "utf-8"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Validate every recipe (parameterised pytest)
run: pytest tests/test_recipes_v031.py tests/test_recipes.py -v --tb=short --no-cov

View File

@ -101,7 +101,7 @@ soup_cli/
experiment/ - SQLite experiment tracking
eval/ - Eval platform (custom tasks, LLM judge, human eval, leaderboard)
migrate/ - Config migration (LLaMA-Factory, Axolotl, Unsloth)
recipes/ - Ready-made configs for popular models (43 recipes)
recipes/ - Ready-made configs for popular models (80 recipes)
autopilot/ - Zero-config decision engine (v0.25.0)
registry/ - Model Registry (hashing, store, diff) (v0.26.0)
cans/ - Shareable .can artifact format (v0.26.0)
@ -109,7 +109,7 @@ soup_cli/
utils/ - GPU, errors, MoE, GaLore, QAT, Unsloth, vLLM, SGLang, Liger, FlashAttn, FSDP, Ring Attention, long-context, quality, curriculum, freeze, dataset-registry, mlx, peft_builder, paths, topology, launcher, mii, pipeline, cut_ce, fp8, gradient_ckpt, kernel_picker, cross_doc_attn, activation_offload, hf, spec_pairing, structured_output, metrics, tracing, auto_quant
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (95 files, 2886 tests)
tests/ - Test suite (96 files, 3607 tests)
examples/ - Real-world config examples and datasets
```
@ -232,6 +232,7 @@ pytest tests/ --cov=soup_cli --cov-report=html
| test_training_speed.py | Training Speed & Memory: CCE, FP8, grad-ckpt tiers, kernel picker, cross-doc attn, activation offload (v0.28.0) |
| test_hf_integration.py | HF Hub Deep Integration: token/endpoint/repo_id, auto-push callback, model card v2, collections, data push, HF Spaces, private-IP SSRF (v0.29.0) |
| test_inference_advanced.py | Inference Excellence: prefix caching, spec-decoding auto-pairing, LoRA hot-swap, structured output, dashboard + /metrics, OpenTelemetry tracing, auto-quant picker (v0.30.0) |
| test_recipes_v031.py | Model & Recipe Breadth: 34 new recipes (vision/audio/reasoning/edge/domain/multimodal); catalog-wide invariants; CI workflow validation (v0.31.0) |
## Making Changes

View File

@ -40,12 +40,13 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
- **Prefix caching**`soup serve --prefix-cache` enables vLLM's automatic prefix cache. Big win for RAG / agent workloads with shared system prompts.
- **Speculative decoding auto-pairing**`soup serve --auto-spec` picks a draft model for you based on the target (Llama 3.1-70B → Llama 3.2-1B, Qwen 2.5-72B → Qwen 2.5-0.5B, etc.).
- **Dynamic LoRA hot-swap**`POST /v1/adapters/activate/<name>` (and `/v1/adapters/deactivate`) switches the active adapter at runtime, no restart required.
- **Structured output**`soup serve --structured-output json --json-schema s.json` or `--structured-output regex --regex-pattern '...'` constrains generation.
- **Continuous-batching dashboard**`soup serve --dashboard` opens a live Rich dashboard and exposes `/metrics` (requests, tokens, active, latency p50/p95).
- **OpenTelemetry tracing**`soup serve --trace --trace-endpoint http://localhost:4317` emits per-request spans. OTLP endpoint SSRF-hardened (RFC1918 / link-local / 0.0.0.0 rejected).
- **Recipe library doubled (46 → 80)** — every popular open-weight model now has a validated Soup recipe. `soup recipes search <model>` to find yours.
- **Vision expansion** — Pixtral-12B, Qwen2-VL (7B + 72B), InternVL 2.5, MiniCPM-V 2.6, plus Llama-3.2-Vision 90B. SFT and GRPO/DPO variants.
- **Audio fine-tuning** — Qwen2-Audio, SeamlessM4T v2 (translation), Whisper-large-v3 (ASR).
- **Reasoning models** — full DeepSeek-R1-Distill set (1.5B/7B/14B/32B Qwen + 8B/70B Llama), Qwen3-Coder 30B, Qwen3-30B-A3B reasoning, Phi-4 reasoning.
- **Edge / on-device** — SmolLM2 (135M / 360M / 1.7B), Qwen2.5 (0.5B / 1.5B / 3B), Gemma 2 2B, Phi-3.5-mini.
- **Domain specialists** — BioMistral, Meditron, CodeLlama (13B / 70B), Magicoder, Mathstral, Nemotron-4 340B, Llama-2-13b-finance.
- **Recipe validation CI** — every PR that touches recipe / config / data code re-validates the full catalog so upstream HF model renames break the build instead of shipping a broken recipe.
## Why Soup?
@ -1613,7 +1614,7 @@ Automatically maps model, LoRA, training params, quantization, and task type. Wa
## Ready-Made Recipes
43 pre-built configs for popular models — no guessing hyperparameters:
80 pre-built configs for popular models — no guessing hyperparameters:
```bash
# List all recipes
@ -1629,9 +1630,24 @@ soup recipes use llama3.1-8b-sft
soup recipes search --task grpo
soup recipes search "reasoning"
soup recipes search --size 7b
soup recipes search "medical"
soup recipes search "vision"
```
Recipes cover Llama 3.1/3.2/4, Qwen 2.5/3, Mistral, Gemma 3, Phi-4, DeepSeek R1/V3, plus MLX Apple Silicon recipes across SFT, DPO, GRPO, KTO, ORPO, SimPO, IPO, PPO, embedding, pretrain, tool-calling, and vision tasks.
**What's covered:**
| Category | Models |
|---|---|
| **General SFT / DPO / GRPO / KTO / ORPO / SimPO / IPO / PPO / Embedding / Pretrain** | Llama 3.1 / 3.2 / 4, Qwen 2.5 / 3, Mistral, Gemma 3, Phi-4, DeepSeek R1 / V3 |
| **Vision (multimodal)** | Llama-3.2-Vision (11B + 90B), Pixtral-12B, Qwen2-VL (7B + 72B), InternVL 2.5, MiniCPM-V 2.6 |
| **Audio (speech)** | Qwen2-Audio, SeamlessM4T v2 (translation), Whisper-large-v3 (ASR) |
| **Reasoning** | All 6 DeepSeek-R1-Distill sizes (Qwen 1.5B / 7B / 14B / 32B + Llama 8B / 70B), Qwen3-Coder 30B, Qwen3-30B-A3B reasoning, Phi-4 reasoning |
| **Small / edge / mobile** | SmolLM2 (135M / 360M / 1.7B), Qwen2.5 (0.5B / 1.5B / 3B), Gemma 2 2B, Phi-3.5-mini, Llama-3.2 (1B / 3B) |
| **Domain specialists** | BioMistral 7B, Meditron 7B (medical) — CodeLlama (13B / 70B), Magicoder 6.7B (code) — Mathstral 7B (math) — Llama-2-13b-finance (FinGPT-style starter) — Nemotron-4 340B |
| **Multimodal reasoning** | Llama-3.2-Vision GRPO, Pixtral DPO |
| **Multi-GPU** | llama3-70b-fsdp2, qwen3-32b-zeropp, deepseek-v3-pipeline |
| **Apple Silicon (MLX)** | llama3.1-8b / qwen3-8b / gemma3-9b SFT-MLX |
| **Tool-calling / agentic** | qwen3-8b-tools, llama4-scout-tools |
## Hyperparameter Sweep

View File

@ -9,9 +9,9 @@ We provide security updates for the following versions:
- **Versions older than 3 minor versions:** No support
Example:
- v0.30.0-0.30.x -- Full support (latest)
- v0.29.0-0.29.x -- Bug-fix support only
- v0.28.x and below -- No support
- v0.31.0-0.31.x -- Full support (latest)
- v0.30.0-0.30.x -- Bug-fix support only
- v0.29.x and below -- No support
## Reporting a Vulnerability

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.30.0"
version = "0.31.0"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "Apache-2.0"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
__version__ = "0.30.0"
__version__ = "0.31.0"

File diff suppressed because it is too large Load Diff

View File

@ -260,17 +260,16 @@ class TestV025NewRecipes:
assert cfg.base == recipe.model
assert cfg.task == recipe.task
def test_catalog_size_is_43(self):
"""Total catalog size is 43 (29 + 9 Part A + 2 Part B tools + 3 Part E MLX).
def test_catalog_size_is_80(self):
"""Total catalog size — grew with each release.
v0.25.0 ships MLX SFT recipes only; DPO/GRPO on MLX are scaffolding and
the dpo/grpo-mlx recipes were removed along with the backend validator
that rejects non-SFT MLX configs at load time.
v0.25.0 shipped 43 recipes (29 + 9 Part A + 2 Part B tools + 3 Part E MLX).
v0.27.0 added 3 multi-GPU recipes -> 46.
v0.31.0 added 34 (vision/audio/reasoning/edge/domain/multimodal) -> 80.
"""
from soup_cli.recipes.catalog import RECIPES
# 43 shipped in v0.25.0; v0.27.0 adds 3 multi-GPU recipes.
assert len(RECIPES) == 46
assert len(RECIPES) == 80
def test_new_recipes_searchable(self):
"""Search returns the new recipes via keyword/task filter."""

487
tests/test_recipes_v031.py Normal file
View File

@ -0,0 +1,487 @@
"""Tests for v0.31.0 — Model & Recipe Breadth.
Adds 34 new recipes across vision, audio, reasoning, small/edge, domain
specialists, and multimodal reasoning groups. Total catalog: 46 -> 80.
Each Part group has its own test class. The final ``TestRecipeCatalog80``
asserts overall catalog invariants and parameterised per-recipe validity.
"""
from __future__ import annotations
import pytest
import yaml
from typer.testing import CliRunner
from soup_cli.cli import app
from soup_cli.config.loader import load_config_from_string
from soup_cli.recipes.catalog import RECIPES, get_recipe, search_recipes
runner = CliRunner()
# ---------------------------------------------------------------------------
# Part A: Vision recipes (6 new)
# ---------------------------------------------------------------------------
PART_A_VISION = [
("llama3.2-vision-90b-sft", "sft", "meta-llama/Llama-3.2-90B-Vision-Instruct"),
("pixtral-12b-sft", "sft", "mistralai/Pixtral-12B-2409"),
("qwen2-vl-7b-sft", "sft", "Qwen/Qwen2-VL-7B-Instruct"),
("qwen2-vl-72b-sft", "sft", "Qwen/Qwen2-VL-72B-Instruct"),
("internvl-2.5-8b-sft", "sft", "OpenGVLab/InternVL2_5-8B"),
("minicpm-v-2.6-sft", "sft", "openbmb/MiniCPM-V-2_6"),
]
class TestPartAVision:
"""6 new vision/multimodal recipes."""
@pytest.mark.parametrize("name,task,model", PART_A_VISION)
def test_recipe_registered(self, name: str, task: str, model: str) -> None:
assert name in RECIPES, f"Missing recipe: {name}"
recipe = get_recipe(name)
assert recipe is not None
assert recipe.task == task
assert recipe.model == model
@pytest.mark.parametrize("name,task,model", PART_A_VISION)
def test_recipe_loads_as_soupconfig(self, name: str, task: str, model: str) -> None:
recipe = get_recipe(name)
cfg = load_config_from_string(recipe.yaml_str)
assert cfg.base == model
assert cfg.task == task
assert cfg.modality == "vision", f"{name} must declare modality: vision"
@pytest.mark.parametrize("name,task,model", PART_A_VISION)
def test_recipe_has_vision_format(self, name: str, task: str, model: str) -> None:
"""Vision recipes must use llava or sharegpt4v format."""
recipe = get_recipe(name)
cfg = load_config_from_string(recipe.yaml_str)
assert cfg.data.format in ("llava", "sharegpt4v")
@pytest.mark.parametrize("query,expected_model", [
("Pixtral", "mistralai/Pixtral-12B-2409"),
("Qwen2-VL", "Qwen/Qwen2-VL-7B-Instruct"),
("Qwen2-VL", "Qwen/Qwen2-VL-72B-Instruct"),
("InternVL", "OpenGVLab/InternVL2_5-8B"),
("MiniCPM", "openbmb/MiniCPM-V-2_6"),
("Llama-3.2-90B", "meta-llama/Llama-3.2-90B-Vision-Instruct"),
])
def test_vision_search_returns_each_new_recipe(
self, query: str, expected_model: str
) -> None:
"""Each new vision model surfaces via keyword search (per-model assertion)."""
results = search_recipes(query=query)
models = {r.model for r in results}
assert expected_model in models, (
f"search('{query}') missed {expected_model}; got {models}"
)
@pytest.mark.parametrize("name,_task,_model", PART_A_VISION)
def test_vision_recipe_sets_image_dir(
self, name: str, _task: str, _model: str
) -> None:
"""Vision recipes must declare image_dir or a non-empty fallback path."""
cfg = load_config_from_string(get_recipe(name).yaml_str)
assert cfg.data.image_dir, f"{name} missing image_dir for vision modality"
# ---------------------------------------------------------------------------
# Part B: Audio recipes (3 new)
# ---------------------------------------------------------------------------
PART_B_AUDIO = [
("qwen2-audio-7b-sft", "sft", "Qwen/Qwen2-Audio-7B-Instruct"),
("seamlessm4t-v2-sft", "sft", "facebook/seamless-m4t-v2-large"),
("whisper-large-v3-ft", "sft", "openai/whisper-large-v3"),
]
class TestPartBAudio:
"""3 new audio fine-tuning recipes."""
@pytest.mark.parametrize("name,task,model", PART_B_AUDIO)
def test_recipe_registered(self, name: str, task: str, model: str) -> None:
assert name in RECIPES
recipe = get_recipe(name)
assert recipe.task == task
assert recipe.model == model
@pytest.mark.parametrize("name,task,model", PART_B_AUDIO)
def test_recipe_loads_with_audio_modality(self, name: str, task: str, model: str) -> None:
recipe = get_recipe(name)
cfg = load_config_from_string(recipe.yaml_str)
assert cfg.modality == "audio", f"{name} must declare modality: audio"
assert cfg.data.format == "audio"
@pytest.mark.parametrize("name,_task,_model", PART_B_AUDIO)
def test_audio_recipe_sets_audio_dir(
self, name: str, _task: str, _model: str
) -> None:
"""Audio recipes must declare audio_dir."""
cfg = load_config_from_string(get_recipe(name).yaml_str)
assert cfg.data.audio_dir, f"{name} missing audio_dir for audio modality"
# ---------------------------------------------------------------------------
# Part C: Reasoning recipes (7 new)
# ---------------------------------------------------------------------------
PART_C_REASONING = [
("r1-distill-qwen-1.5b-grpo", "grpo", "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"),
("r1-distill-qwen-7b-grpo", "grpo", "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"),
("r1-distill-qwen-14b-grpo", "grpo", "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B"),
("r1-distill-llama-70b-grpo", "grpo", "deepseek-ai/DeepSeek-R1-Distill-Llama-70B"),
("qwen3-coder-30b-sft", "sft", "Qwen/Qwen3-Coder-30B-A3B-Instruct"),
("qwen3-30b-a3b-reasoning-grpo", "grpo", "Qwen/Qwen3-30B-A3B"),
("phi4-reasoning-grpo", "grpo", "microsoft/phi-4"),
]
class TestPartCReasoning:
"""7 new reasoning recipes (R1 distills + Qwen3-Coder + Qwen3 reasoning + Phi-4)."""
@pytest.mark.parametrize("name,task,model", PART_C_REASONING)
def test_recipe_registered(self, name: str, task: str, model: str) -> None:
assert name in RECIPES
recipe = get_recipe(name)
assert recipe.task == task
assert recipe.model == model
@pytest.mark.parametrize("name,task,model", PART_C_REASONING)
def test_recipe_loads(self, name: str, task: str, model: str) -> None:
cfg = load_config_from_string(get_recipe(name).yaml_str)
assert cfg.base == model
if task == "grpo":
assert cfg.training.reward_fn is not None, f"{name} must define reward_fn"
@pytest.mark.parametrize("model_id", [
# New in v0.31.0
"deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-7B",
"deepseek-ai/DeepSeek-R1-Distill-Qwen-14B",
"deepseek-ai/DeepSeek-R1-Distill-Llama-70B",
# Already in catalog from earlier releases
"deepseek-ai/DeepSeek-R1-Distill-Qwen-32B",
"deepseek-ai/DeepSeek-R1-Distill-Llama-8B",
])
def test_all_r1_distill_model_ids_registered(self, model_id: str) -> None:
"""All 6 DeepSeek-R1-Distill sizes appear somewhere in the catalog.
4 are added by v0.31.0; 2 ship from earlier releases (32B-Qwen and
8B-Llama via ``deepseek-r1-32b-grpo`` / ``deepseek-r1-8b-grpo``).
"""
registered = {r.model for r in RECIPES.values()}
assert model_id in registered
# ---------------------------------------------------------------------------
# Part D: Small / edge recipes (8 new)
# ---------------------------------------------------------------------------
PART_D_EDGE = [
("qwen2.5-0.5b-sft", "sft", "Qwen/Qwen2.5-0.5B-Instruct"),
("qwen2.5-1.5b-sft", "sft", "Qwen/Qwen2.5-1.5B-Instruct"),
("qwen2.5-3b-sft", "sft", "Qwen/Qwen2.5-3B-Instruct"),
("gemma2-2b-sft", "sft", "google/gemma-2-2b-it"),
("smollm2-135m-sft", "sft", "HuggingFaceTB/SmolLM2-135M-Instruct"),
("smollm2-360m-sft", "sft", "HuggingFaceTB/SmolLM2-360M-Instruct"),
("smollm2-1.7b-sft", "sft", "HuggingFaceTB/SmolLM2-1.7B-Instruct"),
("phi3.5-mini-sft", "sft", "microsoft/Phi-3.5-mini-instruct"),
]
class TestPartDEdge:
"""8 new small / edge model recipes."""
@pytest.mark.parametrize("name,task,model", PART_D_EDGE)
def test_recipe_registered(self, name: str, task: str, model: str) -> None:
assert name in RECIPES
recipe = get_recipe(name)
assert recipe.task == task
assert recipe.model == model
@pytest.mark.parametrize("name,task,model", PART_D_EDGE)
def test_recipe_loads(self, name: str, task: str, model: str) -> None:
cfg = load_config_from_string(get_recipe(name).yaml_str)
assert cfg.base == model
@pytest.mark.parametrize("name,task,model", PART_D_EDGE)
def test_edge_recipes_tagged_edge(self, name: str, task: str, model: str) -> None:
"""Edge recipes should advertise themselves with an 'edge' or 'small'/'tiny' tag."""
recipe = get_recipe(name)
joined = " ".join(recipe.tags).lower()
assert any(tag in joined for tag in ("edge", "tiny", "small", "mobile"))
# ---------------------------------------------------------------------------
# Part E: Domain specialists (8 new)
# ---------------------------------------------------------------------------
PART_E_DOMAIN = [
("biomistral-7b-sft", "sft", "BioMistral/BioMistral-7B"),
("meditron-7b-sft", "sft", "epfl-llm/meditron-7b"),
("codellama-70b-sft", "sft", "codellama/CodeLlama-70b-Instruct-hf"),
("codellama-13b-sft", "sft", "codellama/CodeLlama-13b-Instruct-hf"),
("magicoder-7b-sft", "sft", "ise-uiuc/Magicoder-S-DS-6.7B"),
("nemotron-4-340b-sft", "sft", "nvidia/Nemotron-4-340B-Instruct"),
("llama2-13b-finance-sft", "sft", "meta-llama/Llama-2-13b-hf"),
("mathstral-7b-sft", "sft", "mistralai/Mathstral-7B-v0.1"),
]
class TestPartEDomain:
"""8 new domain specialist recipes (medical, code, finance, math)."""
@pytest.mark.parametrize("name,task,model", PART_E_DOMAIN)
def test_recipe_registered(self, name: str, task: str, model: str) -> None:
assert name in RECIPES
recipe = get_recipe(name)
assert recipe.task == task
assert recipe.model == model
@pytest.mark.parametrize("name,task,model", PART_E_DOMAIN)
def test_recipe_loads(self, name: str, task: str, model: str) -> None:
cfg = load_config_from_string(get_recipe(name).yaml_str)
assert cfg.base == model
def test_domain_tags_present(self) -> None:
"""Each discipline-specific recipe carries its domain tag.
``nemotron-4-340b-sft`` is intentionally omitted: it is a
general-purpose large model with no single discipline tag.
"""
recipe_to_domain = {
"biomistral-7b-sft": "medical",
"meditron-7b-sft": "medical",
"codellama-70b-sft": "code",
"codellama-13b-sft": "code",
"magicoder-7b-sft": "code",
"llama2-13b-finance-sft": "finance",
"mathstral-7b-sft": "math",
}
for name, domain in recipe_to_domain.items():
recipe = get_recipe(name)
assert domain in recipe.tags, f"{name} missing '{domain}' tag (got {recipe.tags})"
# ---------------------------------------------------------------------------
# Part F: Multimodal reasoning (2 new)
# ---------------------------------------------------------------------------
PART_F_MM_REASONING = [
("llama3.2-vision-grpo", "grpo", "meta-llama/Llama-3.2-11B-Vision-Instruct"),
("pixtral-dpo", "dpo", "mistralai/Pixtral-12B-2409"),
]
# Single source of truth across the file — concatenated for catalog-wide
# parametrizations. If any group above is renamed, every consumer below
# updates automatically (closes the divergence vector flagged by TDD M2).
ALL_V031_RECIPES = (
PART_A_VISION + PART_B_AUDIO + PART_C_REASONING
+ PART_D_EDGE + PART_E_DOMAIN + PART_F_MM_REASONING
)
class TestPartFMultimodalReasoning:
"""2 new multimodal alignment / reasoning recipes."""
@pytest.mark.parametrize("name,task,model", PART_F_MM_REASONING)
def test_recipe_registered(self, name: str, task: str, model: str) -> None:
assert name in RECIPES
recipe = get_recipe(name)
assert recipe.task == task
assert recipe.model == model
@pytest.mark.parametrize("name,task,model", PART_F_MM_REASONING)
def test_recipe_loads_with_vision_modality(self, name: str, task: str, model: str) -> None:
cfg = load_config_from_string(get_recipe(name).yaml_str)
assert cfg.modality == "vision"
def test_vision_grpo_recipe_sets_reward_fn(self) -> None:
"""The multimodal GRPO recipe must define a reward_fn (not silent stub)."""
cfg = load_config_from_string(get_recipe("llama3.2-vision-grpo").yaml_str)
assert cfg.training.reward_fn is not None
assert cfg.training.num_generations is not None
# ---------------------------------------------------------------------------
# Part G: Per-recipe parameterised validation (catalog-wide)
# ---------------------------------------------------------------------------
class TestRecipeCatalog80:
"""Catalog-wide invariants after v0.31.0 expansion (46 -> 80 recipes)."""
def test_total_catalog_size_is_80(self) -> None:
assert len(RECIPES) == 80
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_loads_as_soupconfig(self, name: str) -> None:
recipe = RECIPES[name]
cfg = load_config_from_string(recipe.yaml_str)
assert cfg.base == recipe.model, (
f"Recipe '{name}' base mismatch: meta={recipe.model}, yaml={cfg.base}"
)
assert cfg.task == recipe.task
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_meta_matches_yaml(self, name: str) -> None:
recipe = RECIPES[name]
parsed = yaml.safe_load(recipe.yaml_str)
assert parsed.get("base") == recipe.model
# Require an explicit task: line in the YAML so RecipeMeta.task and the
# yaml never silently disagree (task=sft is the schema default but the
# recipe yaml must spell it out for review-ability).
assert "task" in parsed, f"Recipe '{name}' yaml missing explicit task: line"
assert recipe.task == parsed["task"]
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_has_non_empty_tags_and_description(self, name: str) -> None:
recipe = RECIPES[name]
assert recipe.tags, f"Recipe '{name}' has no tags"
assert recipe.description, f"Recipe '{name}' has no description"
assert len(recipe.description) >= 10, f"Recipe '{name}' description too short"
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_model_id_is_safe(self, name: str) -> None:
"""Model ids must be HF-style 'org/name' or local-safe (no traversal markers)."""
model = RECIPES[name].model
# Reject path traversal / null-byte / scheme injection in model strings.
assert "\x00" not in model
assert ".." not in model
assert "://" not in model
# Reject windows-style backslash separators (defense in depth).
assert "\\" not in model
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_default_data_path_is_relative(self, name: str) -> None:
"""Default training data path must be a non-empty relative path."""
recipe = RECIPES[name]
cfg = load_config_from_string(recipe.yaml_str)
train_path = cfg.data.train
assert train_path, f"{name} has empty/missing data.train"
# Paths starting with / on POSIX or with a Windows drive letter are absolute.
assert not train_path.startswith("/"), f"{name} uses absolute data path"
assert not (len(train_path) >= 2 and train_path[1] == ":"), (
f"{name} uses Windows drive-letter absolute path"
)
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_lora_target_modules_set(self, name: str) -> None:
"""``lora.target_modules`` must be 'auto' or a non-empty list — never empty.
An empty target_modules list passes Pydantic validation but produces a
no-op LoRA adapter at training time with no error: a silent failure
mode the catalog must guard against.
"""
cfg = load_config_from_string(RECIPES[name].yaml_str)
modules = cfg.training.lora.target_modules
if isinstance(modules, list):
assert modules, f"{name} has empty lora.target_modules list"
else:
assert modules == "auto", (
f"{name} lora.target_modules must be 'auto' or a non-empty list, "
f"got {modules!r}"
)
@pytest.mark.parametrize("name", sorted(RECIPES.keys()))
def test_every_recipe_max_length_within_bounds(self, name: str) -> None:
"""max_length must be within the schema bounds [64, 1_048_576]."""
cfg = load_config_from_string(RECIPES[name].yaml_str)
assert 64 <= cfg.data.max_length <= 1_048_576, (
f"{name} max_length={cfg.data.max_length} out of [64, 1M]"
)
@pytest.mark.parametrize("name", sorted(
n for n, r in RECIPES.items() if r.task == "grpo"
))
def test_every_grpo_recipe_has_reward_fn_and_num_generations(
self, name: str
) -> None:
"""GRPO recipes must wire reward_fn and num_generations — required for the trainer."""
cfg = load_config_from_string(RECIPES[name].yaml_str)
assert cfg.training.reward_fn is not None, (
f"GRPO recipe '{name}' missing reward_fn"
)
assert cfg.training.num_generations is not None, (
f"GRPO recipe '{name}' missing num_generations"
)
assert cfg.training.num_generations >= 2, (
f"GRPO recipe '{name}' num_generations={cfg.training.num_generations} "
"must be >= 2"
)
# ---------------------------------------------------------------------------
# Part H: Recipe verification CI workflow file present
# ---------------------------------------------------------------------------
class TestPartHVerificationWorkflow:
"""v0.31.0 ships a CI workflow that validates every recipe at PR time.
The 100-step live smoke train requires GPU and is deferred to v0.31.1; for
v0.31.0 the workflow runs the parameterised pytest validation in
test_recipes_v031.py to catch regressions from upstream model id changes.
"""
def test_recipe_validation_workflow_exists(self) -> None:
from pathlib import Path
repo_root = Path(__file__).resolve().parent.parent
workflow = repo_root / ".github" / "workflows" / "recipe-validation.yml"
assert workflow.exists(), (
f"Expected CI workflow at {workflow} to validate recipes on every PR"
)
content = workflow.read_text(encoding="utf-8")
# Must invoke the per-recipe test file
assert "test_recipes_v031.py" in content
def test_recipe_validation_workflow_triggers(self) -> None:
"""Workflow YAML structure: triggered on push + pull_request to main."""
from pathlib import Path
repo_root = Path(__file__).resolve().parent.parent
workflow_path = repo_root / ".github" / "workflows" / "recipe-validation.yml"
# Note: GitHub Actions parses 'on:' as Python True (yaml 1.1 boolean
# alias) — so look up via the truthy key, falling back to 'on'.
parsed = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
triggers = parsed.get(True) or parsed.get("on") or {}
assert "push" in triggers, "workflow must trigger on push"
assert "pull_request" in triggers, "workflow must trigger on pull_request"
for evt in ("push", "pull_request"):
branches = triggers[evt].get("branches", [])
assert "main" in branches, f"{evt} trigger must filter to main branch"
# ---------------------------------------------------------------------------
# CLI smoke (v0.31.0 recipes are reachable via `soup recipes`)
# ---------------------------------------------------------------------------
class TestRecipesCLIv031:
"""v0.31.0 recipes are reachable through `soup recipes show`."""
@pytest.mark.parametrize("name,_task,_model", ALL_V031_RECIPES)
def test_show_recipe(self, name: str, _task: str, _model: str) -> None:
result = runner.invoke(app, ["recipes", "show", name])
assert result.exit_code == 0, (
f"`soup recipes show {name}` failed: {result.output!r}"
)
# The model id appears verbatim in the YAML output.
assert _model in result.output
def test_list_runs_clean(self) -> None:
"""`soup recipes list` exits 0 (Rich table rendering is terminal-width-dependent)."""
result = runner.invoke(app, ["recipes", "list"])
assert result.exit_code == 0, result.output
@pytest.mark.parametrize("query,expected", [
("pixtral", "pixtral-12b-sft"),
("smollm", "smollm2-135m-sft"),
("biomistral", "biomistral-7b-sft"),
("meditron", "meditron-7b-sft"),
("mathstral", "mathstral-7b-sft"),
])
def test_search_finds_v031_recipes(self, query: str, expected: str) -> None:
"""`soup recipes search <kw>` surfaces v0.31.0 recipes by keyword."""
result = runner.invoke(app, ["recipes", "search", query])
assert result.exit_code == 0, result.output
assert expected in result.output, f"{expected} missing from search '{query}'"