mirror of https://github.com/razor-ai/soup.git
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:
parent
6c8f12a1f3
commit
d0b7719858
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
32
README.md
32
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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}'"
|
||||
Loading…
Reference in New Issue