feat(v0.52.0): Modality II — TTS + Distillation + BitNet + EBFT-GDPO + MoE quant + reasoning_effort

7 schema-only Parts; live trainer / loss / export wiring deferred to v0.52.1
(mirrors v0.27.0 MII / v0.50.0 GRPO Plus / v0.51.0 hubs stub-then-live pattern).

- Part A: task='tts' + modality='audio_out' + 5 families (Orpheus/Sesame-CSM/
  Llasa/Spark/Oute) + per-family emotion allowlist (Orpheus 8 / Oute 6) +
  5 recipes (orpheus-tts-sft, sesame-csm-tts, llasa-tts, spark-tts, oute-tts).
- Part B: classifier / reranker / cross_encoder tasks + num_labels (bool-
  before-int guard) + classifier_kind + label_names (dedup + null-byte + cap).
- Part C: task='distill' + teacher_model + distill_divergence (kl alias
  canonicalises to forward_kl; Literal excludes alias) + distill_temperature
  (math.isfinite + [0.05, 100.0] bounds).
- Part D: quantization='bitnet_1.58' (gated to non-MLX + text + task in
  {sft, pretrain, dpo}) + Falcon-E BitNet recipe + soup export
  --format bitnet/tq1_0 CLI stubs (yellow deferred-advisory panel, exit 0).
- Part E: EBFT (structured/strided + bounded ebft_temperature; SFT-only)
  + GDPO (standard/length_normalized/margin; DPO-family-only).
- Part F: moe_expert_quant (nf4/int8_rowwise) + train_router_only — both
  require moe_lora=true (silent-no-op footgun rejection).
- Part G: reasoning_effort (low/medium/high) + train_on_eot — both gated to
  the SFT-family task set (sft/pretrain/distill/classifier/reranker/
  cross_encoder); rejected on DPO/GRPO/PPO/etc. with named offenders.

Review fixes (5 agents: python, security, code, tdd, verification-loop-manual):
- num_labels bool-before-int field_validator (security HIGH)
- reasoning_effort + train_on_eot SoupConfig task-gate (code HIGH)
- _validate_classifier_compat lazy-import early-return (code HIGH)
- Oute emotion allowlist via data-driven _FAMILY_EMOTIONS (python+code MED)
- _MAX_LEN -> _MAX_REASONING_EFFORT_LEN (python MED)
- validate_reasoning_effort wired via field_validator (security MED)
- distill_divergence Literal excludes "kl" alias (code MED)
- DIVERGENCES derived from _DIVERGENCE_ALIASES drift guard (python LOW)
- is_bitnet_model comment fixed to match impl (python LOW)
- sister-fn bool guards on every compat helper (python+security MED)
- TDD coverage gaps closed: EBFT variant oversize, GDPO full rejection matrix,
  ebft_temperature explicit-exc table, TTS compat input guards, recipe
  model-id null/whitespace, full reasoning_effort task-matrix.

Drift fixes:
- tests/test_onnx_tensorrt_export.py + tests/test_awq_gptq_export.py
  SUPPORTED_FORMATS count bumped 5 -> 7 (bitnet + tq1_0 stubs).
- tests/test_recipes.py catalog_size assertion 106 -> 112 (5 TTS + Falcon-E).

Test count: 7184 -> 7456 (+272). 230 new tests in tests/test_v0520.py;
remainder from drift-fix parametrize expansions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-12 13:38:01 +05:00
parent 352f8d4bfd
commit df7f49feda
19 changed files with 3135 additions and 25 deletions

View File

@ -111,7 +111,7 @@ soup_cli/
templates/ - 17 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0)
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (178 files, 7184 tests)
tests/ - Test suite (179 files, 7456 tests)
examples/ - Real-world config examples and datasets
```
@ -262,6 +262,7 @@ pytest tests/ --cov=soup_cli --cov-report=html
| test_v0500_part_c.py | v0.50.0 Part C — Multi-turn agent rollout backend allowlist (art / ruler / nemo_gym / openenv); frozen `RolloutBackendSpec` + `MappingProxyType` immutability; `validate_rollout_backend` (bool rejected); `required_rollout_package` per-entry mapping; `launch_rollout` deferred stub; SoupConfig task-gate + mlx rejection (v0.50.0 Part C) |
| test_v0500_part_d.py | v0.50.0 Part D — 7 stability/efficiency knobs (`ref_model_ema_alpha` / `replay_buffer_size` / `async_grpo_prefetch` / `tis_threshold` / `mask_truncated_completions` / `defer_rerolling` / `skip_zero_advantage` / `off_policy_mask_threshold`); explicit bool-rejection field_validator across all numeric fields (tdd-guide HIGH fix); `mask_truncated_completions` requires `tis_threshold` cross-validator; SoupConfig task-gate naming every offending field; `grpo_fp16` task-gate (code-review HIGH fix) (v0.50.0 Part D) |
| test_v0500_part_e.py | v0.50.0 Part E — `task='prm'` (Process Reward Model) + `vision_grpo` flag; `validate_prm_compat` (data.format / modality / mlx gates); `validate_vision_grpo_compat` (task ∈ {grpo, ppo} / modality='vision' / non-mlx); `build_prm_trainer` deferred stub; SoupConfig integration with all rejection paths exercised (v0.50.0 Part E) |
| test_v0520.py | v0.52.0 Modality II — TTS / classifier / distill / BitNet / EBFT-GDPO / MoE quant / reasoning_effort: TTS family allowlist + per-family emotion allowlists (Orpheus + Oute) + validate_tts_compat; classifier / reranker / cross_encoder tasks + num_labels (with field_validator bool guard) + label_names dedup + classifier-only field gates; distill divergence (kl alias canonicalised, Literal excludes alias) + teacher_model + distill_temperature bounds; BitNet 1.58 quant + bitnet/tq1_0 export-format stubs + Falcon-E recipe + is_bitnet_model org-prefix detect; EBFT (structured/strided) + GDPO (standard/length_normalized/margin) variant allowlists + task gates; MoE expert quant (nf4/int8_rowwise) + train_router_only requiring moe_lora=true; reasoning_effort + train_on_eot with SFT-family task gate; 6 new recipes (5 TTS + Falcon-E BitNet); review-fix coverage (num_labels bool guard, Oute emotion allowlist, lazy-import in classifier validator, task gates, oversize / NaN / Inf matrices). Test count: 272 (v0.52.0) |
| test_v0510.py | v0.51.0 Model Catalog Expansion + Alternative Model Hubs: Part E hubs.py (`SUPPORTED_HUBS` + `validate_hub_name` + `validate_hub_endpoint` SSRF parity / CRLF rejection / IPv6 mapped private rejected / IPv6 loopback ok / control chars; `resolve_endpoint` env-var override; `default_endpoint` + `endpoint_env_var` + `required_hub_package` + `is_hf` with bool guards; MappingProxyType immutability); TrainingConfig `hub` field (default + Literal accept + None reject + case-insensitive normalisation + YAML round-trip) + SoupConfig `_validate_hub_supported` (mlx + non-hf rejected; mlx + hf accepted; modelers + transformers accepted); Part D MULTIPACK_ARCHITECTURES extension (20 new arches parametrize + legacy preserved + exact count=38 + frozenset immutability); Parts A/B/C 26 new recipes (parametrize over every name × {get_recipe / RecipeMeta / SoupConfig load / yaml.safe_load / model id no null/whitespace/empty parts / max_length bounds / GRPO required fields}); baichuan-sft uses `hub: modelscope`; total recipe count >= 105 (v0.51.0) |
(Note: the test-file table above covers v0.25.0v0.35.0 + v0.47.0 + v0.48.0 + v0.49.0 + v0.50.0 only; full per-release table lives in `.claude/CLAUDE.md`.)

110
README.md
View File

@ -43,13 +43,16 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.51.0 — Model Catalog Expansion**: 26 new ready-made recipes covering 25 model families, plus alternative-hub support (ModelScope + Modelers / Openmind) for users in regions where HF Hub is unreachable. Closes the day-zero coverage gap with Unsloth.
**v0.52.0 — Modality II (TTS + Distillation + BitNet + EBFT/GDPO + MoE quant + reasoning_effort)**: 5 TTS model families, classifier / reranker / cross-encoder training, knowledge distillation, BitNet 1.58-bit, Energy-Based FT, Generalized DPO, per-expert MoE quantization, and gpt-oss reasoning-effort schema — schema-only release; live trainer / loss / export wiring lands in v0.52.1.
- **26 new recipes** across reasoning + agent (GPT-OSS 20B/120B, GLM 4.6 / 5, Kimi K2 / K2-Thinking GRPO, MiniMax-M2, QwQ-32B GRPO, QVQ-72B), small / specialist (Granite 4, Liquid LFM2, Cogito v2, Mistral Small 3 / Medium 3.5, Magistral / Devstral / Ministral, MedGemma, EmbeddingGemma), and vision / multimodal (LLaVA-Next, InternVL 3.5, Voxtral, Baichuan 2, Qwen-Image, DeepSeek-OCR, Paddle-OCR-VL). Browse them via `soup recipes list` / `soup recipes search <keyword>`. Catalog grows 80 → 106.
- **Alternative model hubs.** New `training.hub: hf | modelscope | modelers` with full SSRF-hardened endpoint validators (parity with v0.29.0 `HF_ENDPOINT` policy — scheme allowlist, loopback-only HTTP, RFC1918 / link-local / cloud-metadata IP rejection, control-character rejection). `MODELSCOPE_ENDPOINT` / `MODELERS_ENDPOINT` env vars override the default hub URL the same way `HF_ENDPOINT` already does. Schema-only this release; live downloader + uploader wiring lands in v0.51.1.
- **20 new architectures in the multipack allowlist.** `MULTIPACK_ARCHITECTURES` grows 18 → 38 to enable FFD bin-packing on Granite, GLM, Kimi, MiniMax, QwQ, QVQ, GPT-OSS, Magistral, Devstral, Ministral, MedGemma, LFM2, Cogito, Hunyuan, Ernie, Yi, Baichuan, ChatGLM.
- **MLX backend cross-validator.** `backend: mlx` + `hub: modelscope` is now rejected at config-load with a distinct error message (`mlx-lm` only downloads from HF Hub) — prevents a silent runtime confusion.
- **+449 net new tests** (6729 → 7178). 4 sequential review agents (python-review, security-review, code-review, tdd-guide) each fed back HIGH/MEDIUM/LOW findings; every finding was fixed before commit (case-insensitive `hub` field-validator, MLX cross-validator, control-char rejection in endpoint validator, `is_hf` bool guard, exact-count multipack arch invariant, empty-component model-id check).
- **TTS fine-tuning.** New `task='tts'` + `modality='audio_out'` with a closed allowlist of five model families (Orpheus, Sesame-CSM, Llasa, Spark, Oute) and per-family emotion-tag allowlists for Orpheus + Oute. Five new recipes: `orpheus-tts-sft`, `sesame-csm-tts`, `llasa-tts`, `spark-tts`, `oute-tts`.
- **Classifier / reranker / cross-encoder tasks.** New `task` values `classifier`, `reranker`, `cross_encoder` with `num_labels`, `classifier_kind` (single_label / multi_label), `label_names` (1024-cap + dedup + null-byte rejection). Cross-validator requires `num_labels` and matches `len(label_names) == num_labels`.
- **Knowledge distillation.** New `task='distill'` + `teacher_model` (HF id or local path) + `distill_divergence` (`kl` / `forward_kl` / `reverse_kl` / `js``kl` canonicalises to `forward_kl`) + `distill_temperature` (math.isfinite + [0.05, 100.0] bounds).
- **BitNet 1.58-bit + GGUF export schema.** `quantization='bitnet_1.58'` accepted for task ∈ {sft, pretrain, dpo} on transformers/unsloth backends; new BitNet / TQ1_0 export-format allowlist; `falcon-e-bitnet-sft` recipe shipped.
- **EBFT + GDPO.** Energy-Based FT variants (`structured` / `strided`) gated to `task='sft'`; Generalized DPO variants (`standard` / `length_normalized` / `margin`) gated to DPO-family tasks.
- **MoE expert quant + router-only training.** `moe_expert_quant: nf4 | int8_rowwise` and `train_router_only: true` — both require `moe_lora=true` (silent-no-op rejection at config-load).
- **gpt-oss `reasoning_effort: low | medium | high`** + `train_on_eot: bool`. Both gated to the SFT-family task set (sft / pretrain / distill / classifier / reranker / cross_encoder) — non-SFT tasks reject loudly.
- **+272 net new tests** (7184 → 7456). 4 review agents (python-reviewer, security-reviewer, code-reviewer, tdd-guide) ran; every finding fixed: `num_labels` bool-before-int guard, `reasoning_effort` / `train_on_eot` task-gate, Oute emotion allowlist, `_validate_classifier_compat` lazy-import guard, `_MAX_LEN``_MAX_REASONING_EFFORT_LEN`, `DIVERGENCES` derived from alias map, sister-function bool guards on every compat helper, expanded TDD coverage (oversize on EBFT variant, full GDPO rejection matrix, explicit-exc on temperature bounds, TTS compat input guards, recipe model-id null/whitespace check).
## Why Soup?
@ -137,6 +140,10 @@ soup export --model ./output --format awq --bits 4 --group-size 128
# Export to GPTQ quantized model (pip install 'soup-cli[gptq]')
soup export --model ./output --format gptq --bits 4 --group-size 128
# BitNet 1.58-bit + TQ1_0 GGUF (schema-locked in v0.52.0; live conversion in v0.52.1)
soup export --model ./output --format bitnet
soup export --model ./output --format tq1_0
```
## Config Example
@ -3514,6 +3521,97 @@ soup data mix --apply mix_recipe.yaml
Live wiring of the proxy training loop into a short `soup train` run is the v0.48.1 deliverable; v0.48.0 ships a synthetic offline proxy (quadratic penalty around the uniform simplex) so the budget tracker, optimiser surface, and recipe writer can be exercised without GPUs. `scikit-optimize` is opt-in via `OptimizerProtocol`; the default fallback is a deterministic Dirichlet sampler.
## TTS Fine-Tuning (BETA, v0.52.0)
Schema-only this release; live trainer wiring lands in v0.52.1.
Five upstream model families are recognised: `orpheus`, `sesame_csm`, `llasa`, `spark`, `oute`. Pair `task: tts` with `modality: audio_out` and set `training.tts_family`. Orpheus + Oute support emotion conditioning via `training.tts_emotion` from a per-family allowlist (Orpheus: neutral / happy / sad / angry / excited / calm / whisper / laugh; Oute: neutral / happy / sad / angry / calm / excited).
```yaml
base: canopylabs/orpheus-3b-0.1-ft
task: tts
modality: audio_out
data:
train: ./data/tts_train.jsonl
format: audio
audio_dir: ./data/audio
training:
tts_family: orpheus
tts_emotion: neutral
```
Five ready-made recipes ship in v0.52.0: `orpheus-tts-sft`, `sesame-csm-tts`, `llasa-tts`, `spark-tts`, `oute-tts` — copy with `soup recipes use <name>`. Cross-validators reject the `mlx` backend, `modality != audio_out`, and emotion tags outside the per-family allowlist.
## Classifier / Reranker / Cross-Encoder Training (BETA, v0.52.0)
Three new task types build on the existing embedding trainer: `task: classifier` (single-label or multi-label sequence classification), `task: reranker` (pointwise retrieval scoring), `task: cross_encoder` (paired-input scoring). Schema-only; live trainer wrapper in v0.52.1.
```yaml
base: BAAI/bge-base-en-v1.5
task: classifier
data:
train: ./data/classification.jsonl
training:
num_labels: 3
classifier_kind: single_label
label_names: [negative, neutral, positive]
```
`num_labels` is bounded `[1, 1024]` with explicit bool-before-int rejection; `label_names` (optional) must be unique, ≤128 chars each, and match `num_labels` in length when set.
## Knowledge Distillation (BETA, v0.52.0)
New `task: distill` with `training.teacher_model` (HF id or local path), `training.distill_divergence` (`kl` / `forward_kl` / `reverse_kl` / `js``kl` canonicalises to `forward_kl`), and `training.distill_temperature` (bounded `[0.05, 100.0]`, finite-only). Schema-only; live loop in v0.52.1.
```yaml
base: meta-llama/Llama-3.2-1B
task: distill
data:
train: ./data/distill.jsonl
training:
teacher_model: meta-llama/Llama-3.1-8B
distill_divergence: forward_kl
distill_temperature: 2.0
```
The cross-validator rejects `task='distill'` without `teacher_model`, and rejects `teacher_model` / `distill_*` fields when `task` is anything other than `distill`.
## BitNet 1.58-Bit Fine-Tuning (BETA, v0.52.0)
New `training.quantization: bitnet_1.58` for ternary-weight training (axolotl + onebitllms wrapping). Schema-only on the trainer side; the new export targets are wired as CLI stubs:
```bash
# Schema-locked; live export lands in v0.52.1.
soup export --model ./output --format bitnet
soup export --model ./output --format tq1_0
```
A ready-made `falcon-e-bitnet-sft` recipe is shipped:
```bash
soup recipes use falcon-e-bitnet-sft
soup train --config soup.yaml
```
Restricted to `task ∈ {sft, pretrain, dpo}` on `backend ∈ {transformers, unsloth}` with text modality; the cross-validator rejects MLX and vision/audio configurations loudly at config load.
## EBFT + GDPO (BETA, v0.52.0)
Energy-Based Fine-Tuning (axolotl) lands as `training.ebft_variant ∈ {structured, strided}` + `training.ebft_temperature` (bounded `[1e-4, 100.0]`). Gated to `task: sft`. Generalized DPO lands as `training.gdpo_variant ∈ {standard, length_normalized, margin}` — gated to `task ∈ {dpo, preference}`. Live loss kernels in v0.52.1.
## MoE Expert Quantization + Router-Only Training (v0.52.0)
For fused-MoE models trained with `moe_lora: true`, two new toggles ship:
- `training.moe_expert_quant: nf4 | int8_rowwise` — per-expert weight quantization (axolotl).
- `training.train_router_only: true` — freeze every expert and train only the gating router (unsloth pattern).
Both reject silently-no-op combinations: setting either flag without `moe_lora=true` fails at config load with an actionable message.
## gpt-oss `reasoning_effort` + `train_on_eot` (v0.52.0)
`training.reasoning_effort: low | medium | high` injects a system-prefix token at training time for gpt-oss models; `training.train_on_eot: true` includes explicit EOT/EOS control tokens in the SFT loss (axolotl `train_on_eot`). Both are gated to the SFT-family task set (`sft` / `pretrain` / `distill` / `classifier` / `reranker` / `cross_encoder`) — setting them on DPO / GRPO / PPO / etc. fails at config load. Live formatter wiring in v0.52.1.
## Changelog
See [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases) for version history.

View File

@ -9,7 +9,8 @@ We provide security updates for the following versions:
- **Versions older than 3 minor versions:** No support
Example:
- v0.51.0 -- Full support (latest)
- v0.52.0 -- Full support (latest)
- v0.51.0 -- Full support
- v0.50.0 -- Full support
- v0.49.0 -- Full support
- v0.48.0 -- Full support
@ -146,6 +147,7 @@ No known critical vulnerabilities in current releases.
- **v0.32.0 — Training Stability & Auto-Tuning**: `--find-lr-output` containment via shared `utils/paths.is_under_cwd` (prevents writes outside cwd); `save_lr_finder_report` rejects NaN / Infinity floats in `lrs` / `losses` and serialises with `allow_nan=False` (keeps the report parser-safe); `compute_lr_schedule` rejects non-positive `start_lr`, inverted ranges, and `num_steps` outside `[2, 10_000]`; `pick_mixed_precision` rejects empty / null-byte / >200-char model names and resolves multi-version quirks (`qwen2.5` vs `qwen2`, `phi-3.5` vs `phi-3`) by longest-substring-first iteration so an added family can never accidentally make a more-specific entry dead code; `compute_warmup_steps` clamps to `[10, 1000]` with a `ratio==0.0` short-circuit matching HF Trainer's "no warmup" convention; `SpikeRecoveryStrategy` is `@dataclass(frozen=True)` (post-construction mutation cannot bypass validation), `max_attempts ∈ [1, 10]`, `lr_decay ∈ (0, 1)`, `min_lr > 0`; cross-validator `_validate_spike_recovery_requires_watchdog` rejects `loss_spike_recovery=true, loss_watchdog=false` at config-load (fails fast instead of never triggering); `convergence_window ∈ [5, 10_000]`, `convergence_rel_tol ∈ (0, 1]`, `recommend_action` reuses `detect_plateau` so plateau heuristic stays single-source-of-truth; `GradAccumMonitor.recommend()` caps doubled `accum` at `MAX_ACCUM=1024` so a runaway advisory loop cannot blow up DataLoader prefetch; `generate_config` validates BOTH the YAML output path AND the embedded `decisions["output"]` field via `is_under_cwd` (closes the gap where a crafted `decisions["output"]="../../etc"` would have silently propagated into the rendered YAML)
- **v0.34.0 — Observability & Dev UX**: `.crash` bundle generator (`utils/crash.py`) recursively redacts `hf_*` / `sk-*` / `Bearer …` token-shaped strings in any captured `config` and metric tail before serialisation, so a `.crash` file shared on a public GitHub issue cannot leak credentials; `output_dir` is reduced to `os.path.basename` so `$HOME` doesn't leak; `write_crash_bundle` uses `os.path.realpath + commonpath` for cwd containment (Windows-safe; raises `ValueError` not `PermissionError` so callers cannot silently swallow with `except OSError`); filename appends `secrets.token_hex(4)` so two crashes in the same UTC second don't collide; bundle truncated to `MAX_BUNDLE_BYTES=1_000_000`. `train.py` crash-write surfaces failures to the user (no silent missing-bundle). `profiling.py` `resolve_trace_path` rejects empty / `.` / `..` / `/` / `\\` / null-byte `run_id` (closes the `output_dir/profiles/../trace.json` escape) and uses `os.path.realpath + is_under_cwd`; profiles dir is created only on successful torch import (no stale empty dirs on torch-less CI). `tracker.get_run` LIKE-prefix match escapes `%` / `_` / `\\` and uses `ESCAPE '\\'` so a crafted `run_id` cannot widen the match (mirrors v0.26.0 registry policy). Lazy schema migration (`_ensure_schema`) tolerates the "duplicate column" race when two CLI processes start simultaneously on a fresh DB (fork-based multi-GPU training, TUI auto-refresh). `runs.py show/replay/clean` switched user `run_id` rendering to `markup_escape` and switched `clean` containment from broken `Path.resolve() + relative_to()` to project-standard `os.path.realpath + is_under_cwd`. `tui_app.py` lazy-imports `ExperimentTracker` and `markup_escape`s every DB-sourced string before passing into Textual widgets so a crafted base_model / experiment_name cannot inject `[bold red]…[/]` markup. `run_cost.estimate_run_cost_usd` rejects `bool` in `num_gpus` (bool is a subclass of int — same defence as v0.30.0 `Candidate.__post_init__`); duration clamped to `[0, 1 year]`; unknown GPU returns `None` so callers render `—` instead of fabricating `$0.00`. `log_level.parse_log_level` rejects non-string + null-byte input.
- **v0.33.0 — Live Wire**: RLVR `code_exec_reward` adds OS-level isolation (Linux best-effort `os.unshare(CLONE_NEWUSER|CLONE_NEWNET|CLONE_NEWPID)`, macOS `sandbox-exec` with default-deny `MACOS_SANDBOX_PROFILE` narrowed to a 3-name `mach-lookup` allowlist to prevent DNS / NSURLSession bypass of `(deny network*)`); `prune_checkpoints` switches to TOCTOU-safe `os.lstat + S_ISLNK` + `shutil.rmtree(onerror=_abort_on_symlink)` so a symlink encountered mid-walk aborts rather than escapes; `run_gate` wraps each task scorer in a typed `try/except` so backend failures produce `score=None, error=str(exc)` (never silent `score=1.0`); `_parse_judge_url` removes the bare `http://` catch-all (defence-in-depth after the Pydantic GateTask validator); `soup can run` requires `--yes` or explicit consent callback and raises `ValueError` (not `PermissionError`, which is an `OSError` subclass that broad `except` blocks would swallow); GGUF `rglob` result for ollama deploy is `realpath+commonpath` checked against extract_dir (prevents symlink escape from a crafted can); `DeployTarget.path` validator normalises mixed `\\`/`/` separators before splitting (closes a Windows `..` bypass); `CAN_FORMAT_VERSION` 1→2 (additive — v1 still loads); `soup can publish` validates `repo_id` via `utils/hf.validate_repo_id`, resolves token via `resolve_token`, sanitises commit messages (first-line, 200-char cap), uses HTTPS-only HfApi; `_write_spike_recovery_hint` adds `is_under_cwd` containment check on `args.output_dir` from raw HF `TrainingArguments`; `lookup_entry_by_output_dir` emits `ResourceWarning` when 1000-row scan limit is hit (no silent miss); `CrossDocCollator` no longer mutates input feature dicts (HF Dataset rows are cached and reused — mutation broke subsequent batches); `Candidate` rejects `bool` in `score`/`latency_ms` (was sneaking past `int` isinstance check); `evaluate_candidate` latency mean now divides by *completed* prompts (excludes crashed) so a broken candidate isn't artificially fast; `auto_quant.run_auto_quant_picker` soft-falls-back to highest-scored candidate when no candidate clears `min_score` (server still binds); `build_logits_processors` returns `[]` when neither `outlines` nor `lm-format-enforcer` is installed (server degrades to free-form rather than 500); MII server uses loopback-only CORS, max_tokens cap [1, 16384], stream rejection, generic 500 with no stack-trace leak; `os.execvp` auto-reexec uses list args (no shell), all forwarded flags pre-validated; `cleanup_extract_dir` uses `os.path.commonpath` (Windows-safe) instead of `startswith`; `_run_subprocess` catches `TimeoutExpired` and returns rc=124 (coreutils convention) instead of an unhandled traceback; new `eval_results` and `tensorrt` artifact kinds in `RegistryStore._VALID_KINDS`
- **v0.52.0 — Modality II (TTS + Distillation + BitNet + EBFT-GDPO + MoE quant + reasoning_effort)**: 7 schema-only Parts; live trainer / loss / export wiring deferred to v0.52.1. Every new validator follows the project's established hardening policy: closed allowlist (`SUPPORTED_TTS_FAMILIES`, `CLASSIFIER_TASKS`, `DIVERGENCES`, `BITNET_QUANT_FORMATS`, `BITNET_EXPORT_FORMATS`, `EBFT_VARIANTS`, `GDPO_VARIANTS`, `MOE_EXPERT_QUANT_FORMATS`, `REASONING_EFFORT_LEVELS`, per-family `_FAMILY_EMOTIONS`) wrapped in `frozenset` / `MappingProxyType` so registries cannot be mutated at runtime; `validate_*` helpers reject non-string / bool / empty / null-byte / oversize / unknown inputs with case-insensitive normalisation (matches v0.41.0 `validate_optimizer_name` / v0.50.0 `grpo_variant` / v0.51.0 `hub` policy); float validators (`validate_distill_temperature`, `validate_ebft_temperature`) gate on `math.isfinite` to reject NaN AND `±inf` (matches v0.32.0 `save_lr_finder_report` policy). `field_validator(mode="before")` on `num_labels` (security-review HIGH fix) rejects `bool` before Pydantic's `ge=1` coercion silently treats `True` as `1`. Field validator on `reasoning_effort` routes through the shared `validate_reasoning_effort` helper so the schema and runtime validator agree on what's accepted (security-review MEDIUM fix). SoupConfig cross-validators: `_validate_tts_compat` (requires `task='tts'` + `modality='audio_out'` + non-MLX backend; per-family emotion allowlist via `_FAMILY_EMOTIONS`), `_validate_classifier_compat` (with lazy-import early-return — code-review HIGH fix — so SFT hot path doesn't pay import cost; requires `num_labels` on classifier tasks; rejects classifier-only fields outside the task family with named offenders), `_validate_distill_compat` (requires `teacher_model` when `task='distill'`; rejects distill-only fields outside the task), `_validate_bitnet_compat` (gates to non-MLX + text-modality + task ∈ {sft, pretrain, dpo}), `_validate_ebft_compat` + `_validate_gdpo_compat` (task-family gates), `_validate_moe_expert_quant_compat` (requires `moe_lora=true` to prevent silent no-op), `_validate_reasoning_effort_task_gate` (code-review HIGH fix — rejects `reasoning_effort` + `train_on_eot` outside the SFT-family task set with named offenders; mirrors v0.50.0 GRPO stability task-gate policy). Public `DIVERGENCES` frozenset is derived from `_DIVERGENCE_ALIASES` so adding a new alias updates both the accepted-input set and the error message in lockstep (review fix LOW). `validate_bitnet_export` enforces a closed-allowlist canonical form for `soup export --format <bitnet|tq1_0>`, both of which are CLI-registered with a yellow advisory panel + `Exit(0)` stub (no artifact written until v0.52.1 — the format flag is accepted so existing scripts pinned to v0.52.0 will not break). 6 new YAML recipes appended (5 TTS + Falcon-E BitNet) — every entry is exercised by `tests/test_v0520.py` for `load_config_from_string` round-trip + `_no_null_or_whitespace` model-id check (mirrors v0.51.0 review-fix LOW). Known limitations: (1) Every live trainer / loss / export path is deferred to v0.52.1 — `build_tts_trainer`, `build_classifier_trainer`, `build_distill_trainer`, `build_bitnet_trainer`, `export_bitnet_gguf`, `apply_ebft_loss`, `apply_gdpo_loss`, `apply_moe_expert_quant` all raise `NotImplementedError` with explicit `v0.52.1` markers; schema accepts every new task / quant / variant + the CLI stub for `soup export --format bitnet/tq1_0` prints a deferred-advisory panel and exits 0. (2) `modality='audio_out'` accepted on non-TTS tasks — design choice this release so future audio-output tasks (ASR / V2A) can reuse it; today's runtime trainer dispatch must check `task == 'tts'` to avoid silent routing into the deferred TTS path. (3) Oute emotion allowlist is a tight 6-entry subset (neutral / happy / sad / angry / calm / excited); operators wanting custom emotions will need a v0.52.1 patch to extend `OUTE_EMOTIONS`. (4) `is_bitnet_model` is best-effort heuristic over name prefixes (`bitnet`, `falcon-e`, `1bitllm`, `onebit`); a BitNet checkpoint published under an org without any of those prefixes returns False. This is detection, not gating — the trainer wrapper (v0.52.1) loads the model regardless of the heuristic. (5) `quantization='bitnet_1.58'` gated to task ∈ {sft, pretrain, dpo} — extending to GRPO / PPO / RewardModel requires upstream onebitllms RL kernels not yet shipped. (v0.52.0)
- **v0.51.0 — Model Catalog Expansion + Alternative Model Hubs**: 5 release Parts. New `soup_cli/utils/hubs.py` ships closed allowlist `SUPPORTED_HUBS = frozenset({hf, modelscope, modelers})` + three `MappingProxyType`-wrapped registries (`_HUB_DEFAULT_ENDPOINTS` / `_HUB_ENDPOINT_ENV` / `_HUB_PACKAGE`) so the registry cannot be mutated at runtime (matches v0.36.0 `_REGISTRY` policy). `validate_hub_name` rejects non-string / bool / empty / null-byte / >32-char / unknown with case-insensitive normalisation (matches v0.41.0 `validate_optimizer_name` policy). `validate_hub_endpoint` is the SSRF kernel — full parity with v0.29.0 `utils/hf.resolve_endpoint`: scheme allowlist (`http`/`https` only), null-byte rejection, **control-character / CRLF rejection** added in v0.51.0 as a defence-in-depth review fix (defends against URL-as-HTTP-header injection if the URL ever flows into a raw HTTP client), `0.0.0.0` explicitly rejected, plain HTTP only for loopback `{localhost, 127.0.0.1, ::1}`, RFC1918 / link-local / cloud-metadata IPs (169.254.x) rejected via `ipaddress.ip_address` for plain HTTP. `resolve_endpoint(hub, *, env=None)` looks up the per-hub env var (`HF_ENDPOINT` / `MODELSCOPE_ENDPOINT` / `MODELERS_ENDPOINT`) and runs the override through `validate_hub_endpoint`; default endpoints are baked-in HTTPS URLs. `is_hf` rejects `bool` explicitly (review fix HIGH — bool is a subclass of int and would have silently fallen through `hub.lower() == "hf"``False`, which happens to be correct by accident but violates the contract; matches v0.30.0 `Candidate` / v0.34.0 `estimate_run_cost_usd` policy). `TrainingConfig.hub: Literal["hf","modelscope","modelers"]` field gets a `field_validator(mode="before")` `_normalize_hub` that delegates to `validate_hub_name` so `hub: HF` in YAML normalises to `"hf"` (review fix HIGH — first-cut had Pydantic Literal exact-match while `validate_hub_name` was case-insensitive, breaking the v0.41.0 `validate_optimizer_name` / v0.50.0 `grpo_variant` / `rollout_backend` policy of agreement between schema and shared validator). SoupConfig `_validate_hub_supported` cross-validator rejects `hub != 'hf'` on `backend == 'mlx'` with a distinct error message (review fix HIGH — `mlx-lm` only downloads from HF Hub; without this gate a `backend: mlx` + `hub: modelscope` config would silently pass schema load and fail at runtime with a confusing `mlx-lm` error). 26 new YAML recipes appended to `soup_cli/recipes/catalog.py` — every entry is exercised by `tests/test_v0510.py` via `load_config_from_string` round-trip + `yaml.safe_load` (no Python tags / no template injection / no credential leak in the YAML strings) + a `_no_null_or_whitespace` model-id check that rejects empty path components (review fix LOW — first-cut allowed `"/name"` leading-slash IDs to pass). Two non-`<N>B` `size` strings (`"image"` / `"ocr"` / `"moe"` / `"medium"`) were normalised to `"N/A"` (review fix MEDIUM — `search_recipes(size=…)` would silently miss those entries, and the autopilot VRAM estimator could not parse them). Known limitations: (1) Live downloader / uploader / push integration deferred to v0.51.1 — `TrainingConfig.hub` schema lock-in ships now (Literal accept + MLX cross-validator + case-normalisation), but `soup data download --hub modelscope` and `soup push --hub modelers` still route through the existing HF Hub code path; the actual `modelscope-sdk` / `openmind-hub` adapters are the v0.51.1 deliverable. Same stub-then-live pattern as v0.27.0 MII / v0.37.0 multipack / v0.50.0 GRPO Plus. (2) Speculative / aspirational `base` model IDs in some Part A/C recipes — the catalog ships entries for `openai/gpt-oss-{20,120}b`, `THUDM/glm-5`, `Qwen/Qwen-Image`, `deepseek-ai/DeepSeek-OCR`, `PaddlePaddle/PaddleOCR-VL`, `google/embeddinggemma-300m` so users have ready-made recipes the moment those repos go live (matches the plan's "match Unsloth's day-zero coverage" directive). Recipes for not-yet-published repos will surface a clear HF Hub 404 when the user runs `soup train --recipe <name>`. (3) DNS-resolved private hostnames not blocked — `validate_hub_endpoint` only rejects literal RFC1918 / link-local IP addresses; a hostname like `corp-proxy.internal` that DNS-resolves to a private IP is accepted at validation time (mirrors the v0.29.0 `HF_ENDPOINT` policy — DNS resolution is intentionally not performed in this local-tool threat model). (v0.51.0)
- **v0.50.0 — GRPO Plus (RL parity)**: 22 features across 5 Parts shipped as schema-only (closed allowlists + Pydantic validators + NotImplementedError stubs for live wiring deferred to v0.50.1). All new validators follow the project's bool-rejection-before-int policy (matches v0.30.0 `Candidate`); closed-allowlist `validate_grpo_variant` / `validate_rollout_backend` reject non-string / bool / empty / null-byte / oversize / unknown inputs with actionable error messages and case-insensitive normalisation. `validate_grpo_delta` is bool-first / `math.isfinite` / `(0, 1]` bounded (matches v0.32.0 `save_lr_finder_report` / v0.41.0 Part B `lr_groups` policy). New `_VARIANT_METADATA` (Part A) and `_BACKEND_METADATA` (Part C) are `MappingProxyType`-wrapped frozen-dataclass registries (matches v0.36.0 `_REGISTRY` / v0.41.0 `_OPTIMIZER_PACKAGES` policy). Security-review fixes: (1) `grpo_delta` schema gets an explicit `field_validator(mode='after')` calling `math.isfinite` — Pydantic's `gt=0, le=1` bounds only incidentally reject NaN (since `NaN > 0` is False); the explicit validator prevents a future Pydantic change from regressing the guard. (2) `validate_long_context_grpo_compat` adds null-byte rejection on `task` AND `backend` strings + a `bool` guard on `use_ring_attention` (parity with `validate_grpo_variant` / `validate_rollout_backend`). (3) `validate_vllm_sleep_mode_compat` adds null-byte rejection on `backend`. Code-review HIGH fixes: (4) `_validate_grpo_stability_task_gate` now includes `grpo_fp16` in the GRPO-only-fields list — previously a user could silently set `grpo_fp16: true` on `task='sft'` and have it no-op. (5) `_validate_vllm_sleep_mode` now requires `task='grpo'` (sleep mode is a between-rollouts feature, meaningless on SFT) and rejects with a `task='grpo'` message. TDD-review HIGH fixes: (6) new `_reject_bool_on_grpo_numerics` field_validator on every Part D numeric field + `grpo_delta` explicitly rejects `bool` before Pydantic's `True→1` coercion (matches v0.30.0 / v0.41.0 Part B / v0.43.0 Part B policy). Known limitations: (1) Every live loss kernel / launcher (`apply_variant_loss`, `apply_vllm_sleep_mode`, `launch_rollout`, `build_prm_trainer`) raises `NotImplementedError` with explicit `v0.50.1` markers — same stub-then-live pattern as v0.27.0 MII / v0.37.0 multipack / v0.41.0 LLaMA Pro / v0.45.0 plugins / v0.48.0 curriculum / v0.49.0 LongLoRA. (2) `long_context_grpo` requires Tiled MLP (v0.56.0 Part A) to actually run; the schema gate ships now so v0.50.0 configs are stable. (3) `vision_grpo=true` does not check whether the base model is actually a VLM — upstream trainer surfaces that error loudly. (4) The 7 stability knobs schema-validate but none are wired into a live callback in this release; `replay_buffer_size`, `defer_rerolling`, and `skip_zero_advantage` are pure schema lock-ins. (v0.50.0)

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.51.0"
version = "0.52.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.51.0"
__version__ = "0.52.0"

View File

@ -13,7 +13,12 @@ from rich.panel import Panel
console = Console()
SUPPORTED_FORMATS = ("gguf", "onnx", "tensorrt", "awq", "gptq")
SUPPORTED_FORMATS = (
"gguf", "onnx", "tensorrt", "awq", "gptq",
# v0.52.0 Part D — BitNet 1.58-bit + TQ1_0 GGUF.
# Schema-only stubs in v0.52.0; live conversion lands in v0.52.1.
"bitnet", "tq1_0",
)
GGUF_QUANT_TYPES = ("q4_0", "q4_k_m", "q5_k_m", "q8_0", "f16", "f32")
LLAMA_CPP_DIR_NAME = "llama.cpp"
# Pin to a known release tag for supply-chain safety
@ -31,7 +36,7 @@ def export(
"gguf",
"--format",
"-f",
help="Export format: gguf, onnx, tensorrt",
help="Export format: gguf, onnx, tensorrt, awq, gptq, bitnet, tq1_0",
),
quant: str = typer.Option(
"q4_k_m",
@ -147,6 +152,29 @@ def export(
)
return
# --- BitNet 1.58-bit / TQ1_0 GGUF — schema-only stubs (v0.52.0) ---
# Live conversion via onebitllms + llama.cpp TQ1_0 lands in v0.52.1.
if fmt in ("bitnet", "tq1_0"):
from soup_cli.utils.bitnet import validate_bitnet_export
try:
canonical = validate_bitnet_export(fmt)
except (TypeError, ValueError) as exc:
console.print(f"[red]{exc}[/]")
raise typer.Exit(2)
console.print(Panel.fit(
(
f"[yellow]{canonical} export is schema-locked in v0.52.0; "
"live conversion lands in v0.52.1.[/]\n\n"
"The format flag is accepted so existing scripts pinned to "
"v0.52.0 will not break, but no artifact is written yet. "
"Watch the v0.52.1 release notes for the live "
"onebitllms / llama.cpp wiring."
),
title=f"export --format {canonical} (deferred)",
border_style="yellow",
))
raise typer.Exit(0)
if quant not in GGUF_QUANT_TYPES:
console.print(
f"[red]Unsupported quantization: {quant}[/]\n"

View File

@ -705,6 +705,8 @@ class TrainingConfig(BaseModel):
"eetq",
"mxfp4",
"fp8",
# v0.52.0 Part D — BitNet 1.58-bit (axolotl + onebitllms).
"bitnet_1.58",
] = Field(
default="4bit",
description=(
@ -714,7 +716,8 @@ class TrainingConfig(BaseModel):
"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)."
"fp8 (load FP8 checkpoint with dequantize-on-load), "
"bitnet_1.58 (BitNet ternary, v0.52.0 schema-only)."
),
)
gptq_disable_exllama: bool = Field(
@ -1059,6 +1062,243 @@ class TrainingConfig(BaseModel):
),
)
# ---- 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)"
),
)
# 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)"
),
)
# 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)"
),
)
@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("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("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):
@ -1896,16 +2136,24 @@ class SoupConfig(BaseModel):
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",
] = Field(
default="sft",
description=(
"Training task type. v0.50.0 Part E adds 'prm' (Process Reward "
"Model / stepwise supervised — paired with data.format='prm')."
"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)."
),
)
modality: Literal["text", "vision", "audio"] = Field(
modality: Literal["text", "vision", "audio", "audio_out"] = Field(
default="text",
description="Training modality: text (default), vision (multimodal), or audio",
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",
@ -2230,6 +2478,249 @@ class SoupConfig(BaseModel):
)
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
)
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}"
)
return self
@model_validator(mode="after")
def _validate_distill_compat(self) -> "SoupConfig":
"""v0.52.0 Part C — ``task='distill'`` gate."""
tcfg = self.training
distill_fields_set = (
tcfg.teacher_model is not None
or tcfg.distill_divergence is not None
or tcfg.distill_temperature is not None
)
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),
) 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

View File

@ -3241,6 +3241,170 @@ training:
quantization: 4bit
gradient_checkpointing: true
output: ./output
""",
),
# ---- v0.52.0 Modality II — TTS / BitNet / classifier / distill ----
"orpheus-tts-sft": RecipeMeta(
model="canopylabs/orpheus-3b-0.1-ft",
task="tts",
size="3B",
tags=("tts", "orpheus", "audio_out", "v0.52.0"),
description="Orpheus emotional TTS — schema-only stub (live in v0.52.1)",
yaml_str="""\
base: canopylabs/orpheus-3b-0.1-ft
task: tts
modality: audio_out
data:
train: ./data/tts_train.jsonl
format: audio
audio_dir: ./data/audio
max_length: 2048
training:
epochs: 3
lr: 5e-5
batch_size: auto
tts_family: orpheus
tts_emotion: neutral
lora:
r: 16
alpha: 32
target_modules: auto
output: ./output
""",
),
"sesame-csm-tts": RecipeMeta(
model="sesame/csm-1b",
task="tts",
size="1B",
tags=("tts", "sesame", "audio_out", "v0.52.0"),
description="Sesame CSM conversational speech — schema-only stub",
yaml_str="""\
base: sesame/csm-1b
task: tts
modality: audio_out
data:
train: ./data/tts_train.jsonl
format: audio
audio_dir: ./data/audio
max_length: 2048
training:
epochs: 3
lr: 5e-5
batch_size: auto
tts_family: sesame_csm
output: ./output
""",
),
"llasa-tts": RecipeMeta(
model="HKUSTAudio/Llasa-1B",
task="tts",
size="1B",
tags=("tts", "llasa", "audio_out", "v0.52.0"),
description="Llasa-TTS — schema-only stub",
yaml_str="""\
base: HKUSTAudio/Llasa-1B
task: tts
modality: audio_out
data:
train: ./data/tts_train.jsonl
format: audio
audio_dir: ./data/audio
max_length: 2048
training:
epochs: 3
lr: 5e-5
batch_size: auto
tts_family: llasa
output: ./output
""",
),
"spark-tts": RecipeMeta(
model="SparkAudio/Spark-TTS-0.5B",
task="tts",
size="0.5B",
tags=("tts", "spark", "audio_out", "v0.52.0"),
description="Spark-TTS — schema-only stub",
yaml_str="""\
base: SparkAudio/Spark-TTS-0.5B
task: tts
modality: audio_out
data:
train: ./data/tts_train.jsonl
format: audio
audio_dir: ./data/audio
max_length: 2048
training:
epochs: 3
lr: 5e-5
batch_size: auto
tts_family: spark
output: ./output
""",
),
"oute-tts": RecipeMeta(
model="OuteAI/OuteTTS-0.3-500M",
task="tts",
size="0.5B",
tags=("tts", "oute", "audio_out", "emotion", "v0.52.0"),
description="Oute-TTS with emotion conditioning — schema-only stub",
yaml_str="""\
base: OuteAI/OuteTTS-0.3-500M
task: tts
modality: audio_out
data:
train: ./data/tts_train.jsonl
format: audio
audio_dir: ./data/audio
max_length: 2048
training:
epochs: 3
lr: 5e-5
batch_size: auto
tts_family: oute
output: ./output
""",
),
"falcon-e-bitnet-sft": RecipeMeta(
model="tiiuae/Falcon-E-1B-Instruct",
task="sft",
size="1B",
tags=("bitnet", "1.58bit", "falcon-e", "ternary", "v0.52.0"),
description="Falcon-E BitNet 1.58-bit SFT — schema-only stub",
yaml_str="""\
base: tiiuae/Falcon-E-1B-Instruct
task: sft
data:
train: ./data/train.jsonl
format: auto
max_length: 2048
training:
epochs: 3
lr: 1e-4
batch_size: auto
quantization: bitnet_1.58
lora:
r: 16
alpha: 32
target_modules: auto
output: ./output
""",
),

173
soup_cli/utils/bitnet.py Normal file
View File

@ -0,0 +1,173 @@
"""v0.52.0 Part D — BitNet 1.58-bit fine-tuning + export schema helpers.
Schema-only support for ``quantization='bitnet_1.58'`` and the new
``soup export --format bitnet`` / ``--format tq1_0`` GGUF flavours.
Live ``onebitllms`` wrapping + llama.cpp ``TQ1_0`` export wiring are
deferred to v0.52.1 (mirrors v0.50.0 stub-then-live pattern).
"""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
# Closed allowlist of BitNet-flavoured quant strings exposed to YAML.
BITNET_QUANT_FORMATS: frozenset[str] = frozenset({"bitnet_1.58"})
# Closed allowlist of BitNet export targets (the actual export formats).
BITNET_EXPORT_FORMATS: frozenset[str] = frozenset({"bitnet", "tq1_0"})
_BITNET_FAMILY_RE_PREFIXES: tuple[str, ...] = (
"bitnet", "falcon-e", "falcone", "1bitllm", "onebit",
)
@dataclass(frozen=True)
class BitNetSpec:
"""Metadata for the BitNet quant path. Frozen — immutable."""
name: str
description: str
bits: float
live_wired: bool
_BITNET_METADATA: Mapping[str, BitNetSpec] = MappingProxyType({
"bitnet_1.58": BitNetSpec(
name="bitnet_1.58",
description="BitNet 1.58-bit ternary weights (axolotl + onebitllms)",
bits=1.58,
live_wired=False,
),
})
def is_bitnet_quant(value: object) -> bool:
"""Return True iff ``value`` is a BitNet quant string."""
if isinstance(value, bool):
return False
if not isinstance(value, str):
return False
return value in BITNET_QUANT_FORMATS
def is_bitnet_export_format(value: object) -> bool:
"""Return True iff ``value`` is a BitNet export-format string."""
if isinstance(value, bool):
return False
if not isinstance(value, str):
return False
return value in BITNET_EXPORT_FORMATS
def get_bitnet_spec(name: str) -> BitNetSpec:
"""Return the frozen :class:`BitNetSpec` for ``name`` or raise."""
if not is_bitnet_quant(name):
supported = ", ".join(sorted(BITNET_QUANT_FORMATS))
raise ValueError(
f"BitNet quant {name!r} not supported. Supported: {supported}"
)
return _BITNET_METADATA[name]
def is_bitnet_model(model_name: object) -> bool:
"""Best-effort detect whether ``model_name`` references a BitNet family.
Checks every slash-delimited component (lowercased) against the
``_BITNET_FAMILY_RE_PREFIXES`` prefix list. This is intentionally more
permissive than v0.39.0 ``is_gemma4_model`` because BitNet families
typically live under namespaced orgs (``1bitllm/...``, ``OneBitLLM/...``)
rather than being identifiable by repo name alone.
"""
if isinstance(model_name, bool):
return False
if not isinstance(model_name, str):
return False
if not model_name or "\x00" in model_name:
return False
# Check each path component so an org name like "1bitllm/foo" matches
# while still rejecting unrelated substrings (e.g. an SFT model that
# happens to embed "bitnet" inside a description path).
for part in model_name.lower().split("/"):
if any(part.startswith(prefix) for prefix in _BITNET_FAMILY_RE_PREFIXES):
return True
return False
def validate_bitnet_compat(*, task: str, backend: str, modality: str) -> None:
"""Schema-time gate for ``quantization='bitnet_1.58'``.
Rejects:
- non-string / bool args (defence-in-depth).
- ``backend == 'mlx'`` onebitllms is CUDA-only in v0.52.0.
- ``modality != 'text'`` vision/audio BitNet not modelled.
- ``task`` outside {sft, pretrain, dpo} BitNet wiring is text-LM
training only this release.
"""
for name, value in (("task", task), ("backend", backend), ("modality", modality)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
if backend == "mlx":
raise ValueError(
"quantization='bitnet_1.58' is not supported on backend=mlx "
"(onebitllms is CUDA-only). Use backend='transformers'."
)
if modality != "text":
raise ValueError(
f"quantization='bitnet_1.58' is wired for modality='text' only; "
f"got modality={modality!r}"
)
if task not in ("sft", "pretrain", "dpo"):
raise ValueError(
f"quantization='bitnet_1.58' is only wired for "
f"task in (sft, pretrain, dpo); got task={task!r}"
)
def validate_bitnet_export(format_name: object) -> str:
"""Validate a BitNet export-format string. Returns canonical form."""
if isinstance(format_name, bool):
raise TypeError(
f"bitnet export format must not be bool, got {format_name!r}"
)
if not isinstance(format_name, str):
raise TypeError(
f"bitnet export format must be str, "
f"got {type(format_name).__name__}"
)
if not format_name:
raise ValueError("bitnet export format must be non-empty")
if "\x00" in format_name:
raise ValueError(
"bitnet export format must not contain null bytes"
)
canonical = format_name.lower()
if canonical not in BITNET_EXPORT_FORMATS:
supported = ", ".join(sorted(BITNET_EXPORT_FORMATS))
raise ValueError(
f"bitnet export format {format_name!r} not supported. "
f"Supported: {supported}"
)
return canonical
def build_bitnet_trainer() -> None:
"""Live BitNet trainer factory — deferred to v0.52.1."""
raise NotImplementedError(
"BitNet 1.58-bit fine-tuning live wiring deferred to v0.52.1. "
"Schema accepts quantization='bitnet_1.58' but no trainer integration "
"is registered yet."
)
def export_bitnet_gguf() -> None:
"""Live BitNet GGUF export — deferred to v0.52.1."""
raise NotImplementedError(
"BitNet / TQ1_0 GGUF export live wiring deferred to v0.52.1. "
"Schema accepts --format bitnet / --format tq1_0 but no export "
"pipeline is registered yet."
)

View File

@ -0,0 +1,168 @@
"""v0.52.0 Part B — Classifier / reranker / cross_encoder task helpers.
Three new task strings build on the existing v0.16.0 embedding trainer:
* ``classifier`` sequence classification head (single-label / multi-label).
* ``reranker`` pointwise scoring head for retrieval reranking.
* ``cross_encoder`` paired-input scoring (e.g. MS-MARCO-style).
Schema-only release: validators here are reused by the SoupConfig
cross-validator, while the live trainer wrappers ship in v0.52.1
(mirrors v0.50.0 stub-then-live pattern).
"""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
CLASSIFIER_TASKS: frozenset[str] = frozenset(
{"classifier", "reranker", "cross_encoder"}
)
_CLASSIFIER_KIND: frozenset[str] = frozenset({"single_label", "multi_label"})
_MAX_LABELS: int = 1024
_MAX_LABEL_LEN: int = 128
@dataclass(frozen=True)
class ClassifierKindSpec:
"""Metadata for a classifier task. Frozen so callers cannot mutate."""
name: str
description: str
paired_input: bool
live_wired: bool
_CLASSIFIER_METADATA: Mapping[str, ClassifierKindSpec] = MappingProxyType({
"classifier": ClassifierKindSpec(
name="classifier",
description="Sequence classification head (single/multi-label)",
paired_input=False,
live_wired=False,
),
"reranker": ClassifierKindSpec(
name="reranker",
description="Pointwise scoring head for retrieval reranking",
paired_input=False,
live_wired=False,
),
"cross_encoder": ClassifierKindSpec(
name="cross_encoder",
description="Paired-input scoring head (query/document)",
paired_input=True,
live_wired=False,
),
})
def is_classifier_task(task: object) -> bool:
"""Return True iff ``task`` is one of classifier/reranker/cross_encoder."""
if isinstance(task, bool):
return False
if not isinstance(task, str):
return False
return task in CLASSIFIER_TASKS
def get_classifier_spec(task: str) -> ClassifierKindSpec:
"""Return the frozen :class:`ClassifierKindSpec` for ``task`` or raise."""
if not is_classifier_task(task):
supported = ", ".join(sorted(CLASSIFIER_TASKS))
raise ValueError(
f"task {task!r} is not a classifier task. Supported: {supported}"
)
return _CLASSIFIER_METADATA[task]
def validate_num_labels(value: object) -> int:
"""Validate a ``num_labels`` integer (1..1024). Rejects bool."""
if isinstance(value, bool):
raise TypeError(f"num_labels must not be bool, got {value!r}")
if not isinstance(value, int):
raise TypeError(
f"num_labels must be int, got {type(value).__name__}"
)
if value < 1:
raise ValueError(f"num_labels must be >= 1, got {value}")
if value > _MAX_LABELS:
raise ValueError(
f"num_labels must be <= {_MAX_LABELS}, got {value}"
)
return value
def validate_label_names(value: object) -> list[str]:
"""Validate an optional label-name list. Returns a defensive copy."""
if not isinstance(value, list):
raise TypeError(
f"label_names must be a list, got {type(value).__name__}"
)
if len(value) > _MAX_LABELS:
raise ValueError(
f"label_names too long (max {_MAX_LABELS} entries)"
)
seen: set[str] = set()
result: list[str] = []
for entry in value:
if isinstance(entry, bool):
raise TypeError("label_names entries must not be bool")
if not isinstance(entry, str):
raise TypeError(
f"label_names entries must be str, got {type(entry).__name__}"
)
if not entry:
raise ValueError("label_names entries must be non-empty")
if "\x00" in entry:
raise ValueError("label_names entries must not contain null bytes")
if len(entry) > _MAX_LABEL_LEN:
raise ValueError(
f"label_names entry too long (max {_MAX_LABEL_LEN} chars)"
)
if entry in seen:
raise ValueError(f"label_names entries must be unique: {entry!r}")
seen.add(entry)
result.append(entry)
return result
def validate_classifier_compat(*, task: str, backend: str, modality: str) -> None:
"""Schema-time gate for the three classifier tasks.
Rejects:
- non-string / bool args (defence-in-depth).
- non-classifier task.
- ``backend == 'mlx'`` (no MLX classifier path in v0.52.0).
- ``modality != 'text'`` (vision/audio classifier deferred).
"""
for name, value in (("task", task), ("backend", backend), ("modality", modality)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
if task not in CLASSIFIER_TASKS:
supported = ", ".join(sorted(CLASSIFIER_TASKS))
raise ValueError(
f"validate_classifier_compat called with task={task!r}; "
f"expected one of {supported}"
)
if backend == "mlx":
raise ValueError(
f"task={task!r} is not supported on backend=mlx in v0.52.0"
)
if modality != "text":
raise ValueError(
f"task={task!r} requires modality='text'; got modality={modality!r}"
)
def build_classifier_trainer() -> None:
"""Live classifier trainer factory — deferred to v0.52.1."""
raise NotImplementedError(
"Classifier / reranker / cross_encoder trainer live wiring deferred "
"to v0.52.1. Schema accepts the task but no trainer wrapper is "
"registered yet."
)

200
soup_cli/utils/distill.py Normal file
View File

@ -0,0 +1,200 @@
"""v0.52.0 Part C — Knowledge Distillation schema helpers.
Schema-only support for ``task='distill'`` teacher/student training.
Four divergence options are recognised, mirroring axolotl's distillation
plugin:
* ``kl`` (forward KL student KL teacher, standard distillation)
* ``forward_kl`` (alias for ``kl``)
* ``reverse_kl`` (teacher KL student)
* ``js`` (Jensen-Shannon, symmetric)
The live distillation trainer lands in v0.52.1; this module exposes pure
validators so the schema gate can fail fast on misconfiguration.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
_DIVERGENCE_ALIASES: Mapping[str, str] = MappingProxyType({
"kl": "forward_kl",
"forward_kl": "forward_kl",
"reverse_kl": "reverse_kl",
"js": "js",
})
# Public, derived from the alias map so adding a new alias updates both the
# accepted-input set and the error message in lockstep.
DIVERGENCES: frozenset[str] = frozenset(_DIVERGENCE_ALIASES)
_MAX_TEACHER_LEN: int = 512
_MAX_DIVERGENCE_LEN: int = 16
_MIN_TEMPERATURE: float = 0.05
_MAX_TEMPERATURE: float = 100.0
@dataclass(frozen=True)
class DivergenceSpec:
"""Metadata for a divergence kernel. Frozen so callers cannot mutate."""
name: str
description: str
symmetric: bool
live_wired: bool
_DIVERGENCE_METADATA: Mapping[str, DivergenceSpec] = MappingProxyType({
"forward_kl": DivergenceSpec(
name="forward_kl",
description="Forward KL (standard distillation)",
symmetric=False,
live_wired=False,
),
"reverse_kl": DivergenceSpec(
name="reverse_kl",
description="Reverse KL (mode-seeking)",
symmetric=False,
live_wired=False,
),
"js": DivergenceSpec(
name="js",
description="Jensen-Shannon (symmetric KL)",
symmetric=True,
live_wired=False,
),
})
def validate_divergence(name: object) -> str:
"""Validate a divergence name and return the canonical form.
Accepts ``kl`` as an alias for ``forward_kl``. Mirrors v0.41.0
``validate_optimizer_name`` policy.
"""
if isinstance(name, bool):
raise TypeError(f"distill_divergence must not be bool, got {name!r}")
if not isinstance(name, str):
raise TypeError(
f"distill_divergence must be str, got {type(name).__name__}"
)
if not name:
raise ValueError("distill_divergence must be non-empty")
if "\x00" in name:
raise ValueError("distill_divergence must not contain null bytes")
if len(name) > _MAX_DIVERGENCE_LEN:
raise ValueError(
f"distill_divergence too long (max {_MAX_DIVERGENCE_LEN} chars)"
)
canonical = name.lower()
if canonical not in _DIVERGENCE_ALIASES:
supported = ", ".join(sorted(DIVERGENCES))
raise ValueError(
f"distill_divergence {name!r} not supported. Supported: {supported}"
)
return _DIVERGENCE_ALIASES[canonical]
def get_divergence_spec(name: str) -> DivergenceSpec:
"""Return the frozen :class:`DivergenceSpec` for ``name`` or raise."""
canonical = validate_divergence(name)
return _DIVERGENCE_METADATA[canonical]
def validate_distill_temperature(value: object) -> float:
"""Validate a distillation temperature scalar.
Bounds [0.05, 100.0]. Rejects bool, NaN, ±inf.
"""
if isinstance(value, bool):
raise TypeError(
f"distill_temperature must not be bool, got {value!r}"
)
if not isinstance(value, (int, float)):
raise TypeError(
f"distill_temperature must be float, got {type(value).__name__}"
)
fval = float(value)
if not math.isfinite(fval):
raise ValueError(
f"distill_temperature must be finite, got {value!r}"
)
if fval < _MIN_TEMPERATURE:
raise ValueError(
f"distill_temperature must be >= {_MIN_TEMPERATURE}, got {fval}"
)
if fval > _MAX_TEMPERATURE:
raise ValueError(
f"distill_temperature must be <= {_MAX_TEMPERATURE}, got {fval}"
)
return fval
def validate_teacher_model(value: object) -> str:
"""Validate a teacher model string (HF repo id or local path).
Mirrors the v0.40.5 ``reward_model`` field validator: null-byte
rejection + 512-char cap.
"""
if isinstance(value, bool):
raise TypeError(f"teacher_model must not be bool, got {value!r}")
if not isinstance(value, str):
raise TypeError(
f"teacher_model must be str, got {type(value).__name__}"
)
if not value:
raise ValueError("teacher_model must be non-empty")
if "\x00" in value:
raise ValueError("teacher_model must not contain null bytes")
if len(value) > _MAX_TEACHER_LEN:
raise ValueError(
f"teacher_model too long (max {_MAX_TEACHER_LEN} chars)"
)
return value
def validate_distill_compat(
*,
task: str,
backend: str,
teacher_model: object,
) -> None:
"""Schema-time gate for ``task='distill'``.
Rejects:
- non-distill task.
- ``backend == 'mlx'`` (no MLX teacher-load path yet).
- missing teacher_model distillation is meaningless without one.
"""
for name, value in (("task", task), ("backend", backend)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
if task != "distill":
raise ValueError(
f"validate_distill_compat called with task={task!r} "
"(expected 'distill')"
)
if backend == "mlx":
raise ValueError(
"task='distill' is not supported on backend=mlx in v0.52.0"
)
if teacher_model is None:
raise ValueError(
"task='distill' requires training.teacher_model to be set"
)
# Reuse the standard validator — null-byte / oversize / type check.
validate_teacher_model(teacher_model)
def build_distill_trainer() -> None:
"""Live distillation trainer factory — deferred to v0.52.1."""
raise NotImplementedError(
"Distillation trainer (task='distill') live wiring deferred to "
"v0.52.1. Schema accepts the value but no trainer wrapper is "
"registered yet."
)

191
soup_cli/utils/ebft_gdpo.py Normal file
View File

@ -0,0 +1,191 @@
"""v0.52.0 Part E — Energy-Based FT (EBFT) + Generalized DPO (GDPO) helpers.
Schema-only release: each algorithm has a closed allowlist of variant names
plus pure validators. Live loss kernels land in v0.52.1.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
# Closed allowlists.
EBFT_VARIANTS: frozenset[str] = frozenset({"structured", "strided"})
GDPO_VARIANTS: frozenset[str] = frozenset({"standard", "length_normalized", "margin"})
_MAX_VARIANT_LEN: int = 32
_MIN_EBFT_TEMP: float = 1e-4
_MAX_EBFT_TEMP: float = 100.0
@dataclass(frozen=True)
class EBFTSpec:
"""Metadata for an EBFT variant. Frozen — immutable."""
name: str
description: str
live_wired: bool
_EBFT_METADATA: Mapping[str, EBFTSpec] = MappingProxyType({
"structured": EBFTSpec(
name="structured",
description="Structured Energy-Based FT (per-token energies)",
live_wired=False,
),
"strided": EBFTSpec(
name="strided",
description="Strided Energy-Based FT (block-sampled energies)",
live_wired=False,
),
})
@dataclass(frozen=True)
class GDPOSpec:
"""Metadata for a GDPO variant. Frozen — immutable."""
name: str
description: str
live_wired: bool
_GDPO_METADATA: Mapping[str, GDPOSpec] = MappingProxyType({
"standard": GDPOSpec(
name="standard",
description="Standard GDPO (general preference objective)",
live_wired=False,
),
"length_normalized": GDPOSpec(
name="length_normalized",
description="Length-normalized GDPO (SimPO-style normalisation)",
live_wired=False,
),
"margin": GDPOSpec(
name="margin",
description="Margin-augmented GDPO (DPO + margin term)",
live_wired=False,
),
})
def _validate_variant(name: object, allowed: frozenset[str], label: str) -> str:
"""Shared variant-name validator."""
if isinstance(name, bool):
raise TypeError(f"{label} must not be bool, got {name!r}")
if not isinstance(name, str):
raise TypeError(f"{label} must be str, got {type(name).__name__}")
if not name:
raise ValueError(f"{label} must be non-empty")
if "\x00" in name:
raise ValueError(f"{label} must not contain null bytes")
if len(name) > _MAX_VARIANT_LEN:
raise ValueError(
f"{label} too long (max {_MAX_VARIANT_LEN} chars)"
)
canonical = name.lower()
if canonical not in allowed:
supported = ", ".join(sorted(allowed))
raise ValueError(
f"{label} {name!r} not supported. Supported: {supported}"
)
return canonical
def validate_ebft_variant(name: object) -> str:
"""Validate an EBFT variant and return the canonical form."""
return _validate_variant(name, EBFT_VARIANTS, "ebft_variant")
def validate_gdpo_variant(name: object) -> str:
"""Validate a GDPO variant and return the canonical form."""
return _validate_variant(name, GDPO_VARIANTS, "gdpo_variant")
def get_ebft_spec(name: str) -> EBFTSpec:
"""Return the frozen :class:`EBFTSpec` for ``name`` or raise."""
return _EBFT_METADATA[validate_ebft_variant(name)]
def get_gdpo_spec(name: str) -> GDPOSpec:
"""Return the frozen :class:`GDPOSpec` for ``name`` or raise."""
return _GDPO_METADATA[validate_gdpo_variant(name)]
def validate_ebft_temperature(value: object) -> float:
"""Validate an EBFT temperature scalar in [1e-4, 100]. Rejects bool/NaN."""
if isinstance(value, bool):
raise TypeError(f"ebft_temperature must not be bool, got {value!r}")
if not isinstance(value, (int, float)):
raise TypeError(
f"ebft_temperature must be float, got {type(value).__name__}"
)
fval = float(value)
if not math.isfinite(fval):
raise ValueError(
f"ebft_temperature must be finite, got {value!r}"
)
if fval < _MIN_EBFT_TEMP:
raise ValueError(
f"ebft_temperature must be >= {_MIN_EBFT_TEMP}, got {fval}"
)
if fval > _MAX_EBFT_TEMP:
raise ValueError(
f"ebft_temperature must be <= {_MAX_EBFT_TEMP}, got {fval}"
)
return fval
def _check_task_backend(task: object, backend: object) -> None:
"""Shared bool/str guard for cross-compat helpers."""
for name, value in (("task", task), ("backend", backend)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
def validate_ebft_compat(*, task: str, backend: str) -> None:
"""Schema-time gate for ``ebft_variant`` — SFT-only, non-MLX."""
_check_task_backend(task, backend)
if backend == "mlx":
raise ValueError(
"ebft_variant is not supported on backend=mlx in v0.52.0"
)
if task != "sft":
raise ValueError(
f"ebft_variant requires task='sft'; got task={task!r}"
)
def validate_gdpo_compat(*, task: str, backend: str) -> None:
"""Schema-time gate for ``gdpo_variant`` — DPO-family-only, non-MLX."""
_check_task_backend(task, backend)
if backend == "mlx":
raise ValueError(
"gdpo_variant is not supported on backend=mlx in v0.52.0"
)
if task not in ("dpo", "preference"):
raise ValueError(
f"gdpo_variant requires task in ('dpo', 'preference'); "
f"got task={task!r}"
)
def apply_ebft_loss() -> None:
"""Live EBFT loss kernel — deferred to v0.52.1."""
raise NotImplementedError(
"EBFT (Energy-Based FT) live loss kernel deferred to v0.52.1. "
"Schema accepts the variant but no loss is wired yet."
)
def apply_gdpo_loss() -> None:
"""Live GDPO loss kernel — deferred to v0.52.1."""
raise NotImplementedError(
"GDPO (Generalized DPO) live loss kernel deferred to v0.52.1. "
"Schema accepts the variant but no loss is wired yet."
)

141
soup_cli/utils/moe_quant.py Normal file
View File

@ -0,0 +1,141 @@
"""v0.52.0 Part F — MoE expert quantization + router-only training schema.
Two new TrainingConfig fields are introduced this release:
* ``moe_expert_quant: Optional[Literal["nf4", "int8_rowwise"]]`` per-expert
weight quantization for fused-MoE Linear blocks. Wraps axolotl's MoE
expert quant path.
* ``train_router_only: bool`` freeze every expert + train only the
gating router (unsloth MoE recipe). Useful for router calibration.
Schema-only this release; live wiring lands in v0.52.1.
"""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
MOE_EXPERT_QUANT_FORMATS: frozenset[str] = frozenset({"nf4", "int8_rowwise"})
_MAX_QUANT_LEN: int = 32
@dataclass(frozen=True)
class MoEExpertQuantSpec:
"""Metadata for a MoE-expert quant format. Frozen."""
name: str
description: str
bits: int
live_wired: bool
_MOE_EXPERT_QUANT_METADATA: Mapping[str, MoEExpertQuantSpec] = MappingProxyType({
"nf4": MoEExpertQuantSpec(
name="nf4",
description="NF4 per-expert (BNB 4-bit Normal-Float)",
bits=4,
live_wired=False,
),
"int8_rowwise": MoEExpertQuantSpec(
name="int8_rowwise",
description="INT8 row-wise per-expert (LLM.int8 row-wise)",
bits=8,
live_wired=False,
),
})
def validate_moe_expert_quant(name: object) -> str:
"""Validate a MoE expert-quant name. Returns canonical form."""
if isinstance(name, bool):
raise TypeError(f"moe_expert_quant must not be bool, got {name!r}")
if not isinstance(name, str):
raise TypeError(
f"moe_expert_quant must be str, got {type(name).__name__}"
)
if not name:
raise ValueError("moe_expert_quant must be non-empty")
if "\x00" in name:
raise ValueError("moe_expert_quant must not contain null bytes")
if len(name) > _MAX_QUANT_LEN:
raise ValueError(
f"moe_expert_quant too long (max {_MAX_QUANT_LEN} chars)"
)
canonical = name.lower()
if canonical not in MOE_EXPERT_QUANT_FORMATS:
supported = ", ".join(sorted(MOE_EXPERT_QUANT_FORMATS))
raise ValueError(
f"moe_expert_quant {name!r} not supported. "
f"Supported: {supported}"
)
return canonical
def get_moe_expert_quant_spec(name: str) -> MoEExpertQuantSpec:
"""Return the frozen spec for ``name`` or raise."""
canonical = validate_moe_expert_quant(name)
return _MOE_EXPERT_QUANT_METADATA[canonical]
def validate_moe_expert_quant_compat(*, backend: str, moe_lora: bool) -> None:
"""Schema-time gate for ``moe_expert_quant``.
Rejects:
- non-string ``backend`` / non-bool ``moe_lora`` (defence-in-depth).
- ``backend == 'mlx'`` (no MLX MoE expert quant).
- ``moe_lora == False`` (MoE expert quant only meaningful when training
LoRA adapters that sit on top of fused-MoE experts otherwise the
operator is asking for full-precision MoE training with quantized
experts, which silently no-ops).
"""
if isinstance(backend, bool):
raise TypeError(f"backend must not be bool, got {backend!r}")
if not isinstance(backend, str) or not backend:
raise ValueError("backend must be a non-empty string")
if not isinstance(moe_lora, bool):
raise TypeError(f"moe_lora must be bool, got {type(moe_lora).__name__}")
if backend == "mlx":
raise ValueError(
"moe_expert_quant is not supported on backend=mlx in v0.52.0"
)
if not moe_lora:
raise ValueError(
"moe_expert_quant requires moe_lora=true "
"(per-expert quant is only meaningful with MoE-aware LoRA wiring)"
)
def validate_train_router_only_compat(*, backend: str, moe_lora: bool) -> None:
"""Schema-time gate for ``train_router_only=True``.
Requires ``moe_lora=true`` (without it, every expert would still be
trained and the flag would silently no-op). Defence-in-depth bool /
str rejection on the args.
"""
if isinstance(backend, bool):
raise TypeError(f"backend must not be bool, got {backend!r}")
if not isinstance(backend, str) or not backend:
raise ValueError("backend must be a non-empty string")
if not isinstance(moe_lora, bool):
raise TypeError(f"moe_lora must be bool, got {type(moe_lora).__name__}")
if backend == "mlx":
raise ValueError(
"train_router_only is not supported on backend=mlx in v0.52.0"
)
if not moe_lora:
raise ValueError(
"train_router_only requires moe_lora=true "
"(router-only training freezes the experts, which is only "
"meaningful with MoE-aware LoRA wiring)"
)
def apply_moe_expert_quant() -> None:
"""Live MoE expert-quant wiring — deferred to v0.52.1."""
raise NotImplementedError(
"MoE expert quantization live wiring deferred to v0.52.1. "
"Schema accepts the format but no per-expert quant path is wired."
)

View File

@ -0,0 +1,37 @@
"""v0.52.0 Part G — gpt-oss reasoning_effort schema helper.
Schema-only support for ``training.reasoning_effort: Literal["low","medium","high"]``
mirroring the unsloth gpt-oss training recipe. Routes through the prompt
prefix at training time; live formatter wiring lands in v0.52.1.
"""
from __future__ import annotations
REASONING_EFFORT_LEVELS: frozenset[str] = frozenset({"low", "medium", "high"})
_MAX_REASONING_EFFORT_LEN: int = 16
def validate_reasoning_effort(value: object) -> str:
"""Validate a reasoning_effort string and return the canonical form."""
if isinstance(value, bool):
raise TypeError(f"reasoning_effort must not be bool, got {value!r}")
if not isinstance(value, str):
raise TypeError(
f"reasoning_effort must be str, got {type(value).__name__}"
)
if not value:
raise ValueError("reasoning_effort must be non-empty")
if "\x00" in value:
raise ValueError("reasoning_effort must not contain null bytes")
if len(value) > _MAX_REASONING_EFFORT_LEN:
raise ValueError(
f"reasoning_effort too long (max {_MAX_REASONING_EFFORT_LEN} chars)"
)
canonical = value.lower()
if canonical not in REASONING_EFFORT_LEVELS:
supported = ", ".join(sorted(REASONING_EFFORT_LEVELS))
raise ValueError(
f"reasoning_effort {value!r} not supported. Supported: {supported}"
)
return canonical

214
soup_cli/utils/tts.py Normal file
View File

@ -0,0 +1,214 @@
"""v0.52.0 Part A — TTS (text-to-speech) fine-tuning schema helpers.
Schema-only support for ``task='tts'`` paired with ``modality='audio_out'``.
Five upstream TTS model families are recognised: orpheus / sesame_csm /
llasa / spark / oute. Each has a stable name string + per-family ``emotion``
allowlist so trainer wiring (deferred to v0.52.1) can route correctly.
Mirrors v0.50.0 stub-then-live pattern: this module exposes pure validators
and a frozen ``TTSFamilySpec`` dataclass; the live ``TTSTrainerWrapper``
lands in v0.52.1.
Security:
- Pure schema-time validation; no filesystem touch.
- All validators raise ``ValueError`` / ``TypeError`` with actionable
messages.
- Bool rejected before int / str checks (project bool-as-int policy
matches v0.30.0 ``Candidate``).
"""
from __future__ import annotations
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping
# Closed allowlist — wrapped via MappingProxyType so callers cannot mutate
# the registry at runtime (mirrors v0.36.0 ``_REGISTRY`` / v0.51.0 ``hubs``
# policy).
SUPPORTED_TTS_FAMILIES: frozenset[str] = frozenset(
{"orpheus", "sesame_csm", "llasa", "spark", "oute"}
)
_MAX_TTS_FAMILY_LEN: int = 32
_MAX_EMOTION_LEN: int = 32
@dataclass(frozen=True)
class TTSFamilySpec:
"""Metadata for a TTS family. Frozen so callers cannot mutate."""
name: str
description: str
supports_emotion: bool
live_wired: bool
_TTS_FAMILY_METADATA: Mapping[str, TTSFamilySpec] = MappingProxyType({
"orpheus": TTSFamilySpec(
name="orpheus",
description="Orpheus emotional TTS (canopylabs)",
supports_emotion=True,
live_wired=False,
),
"sesame_csm": TTSFamilySpec(
name="sesame_csm",
description="Sesame CSM conversational speech",
supports_emotion=False,
live_wired=False,
),
"llasa": TTSFamilySpec(
name="llasa",
description="Llasa-TTS (HKUSTAudio)",
supports_emotion=False,
live_wired=False,
),
"spark": TTSFamilySpec(
name="spark",
description="Spark-TTS (SparkAudio)",
supports_emotion=False,
live_wired=False,
),
"oute": TTSFamilySpec(
name="oute",
description="Oute-TTS (outeai)",
supports_emotion=True,
live_wired=False,
),
})
# Per-family emotion allowlists. Orpheus + Oute support emotion conditioning;
# the others ignore the tag. Closed allowlist keeps trainer dispatch
# deterministic.
ORPHEUS_EMOTIONS: frozenset[str] = frozenset({
"neutral", "happy", "sad", "angry", "excited", "calm", "whisper", "laugh",
})
# Oute supports a tighter set focused on prosody / register.
OUTE_EMOTIONS: frozenset[str] = frozenset({
"neutral", "happy", "sad", "angry", "calm", "excited",
})
_FAMILY_EMOTIONS: Mapping[str, frozenset[str]] = MappingProxyType({
"orpheus": ORPHEUS_EMOTIONS,
"oute": OUTE_EMOTIONS,
})
def validate_tts_family(name: object) -> str:
"""Validate a TTS family name and return the canonical (lowercase) form."""
if isinstance(name, bool):
raise TypeError(f"tts_family must not be bool, got {name!r}")
if not isinstance(name, str):
raise TypeError(
f"tts_family must be str, got {type(name).__name__}"
)
if not name:
raise ValueError("tts_family must be non-empty")
if "\x00" in name:
raise ValueError("tts_family must not contain null bytes")
if len(name) > _MAX_TTS_FAMILY_LEN:
raise ValueError(
f"tts_family too long (max {_MAX_TTS_FAMILY_LEN} chars)"
)
canonical = name.lower()
if canonical not in SUPPORTED_TTS_FAMILIES:
supported = ", ".join(sorted(SUPPORTED_TTS_FAMILIES))
raise ValueError(
f"tts_family {name!r} not supported. Supported: {supported}"
)
return canonical
def get_tts_family_spec(name: str) -> TTSFamilySpec:
"""Return the frozen :class:`TTSFamilySpec` for ``name`` or raise."""
canonical = validate_tts_family(name)
return _TTS_FAMILY_METADATA[canonical]
def family_supports_emotion(name: str) -> bool:
"""Whether the named family supports an ``emotion`` tag."""
canonical = validate_tts_family(name)
return _TTS_FAMILY_METADATA[canonical].supports_emotion
def validate_emotion_tag(emotion: object, *, family: str) -> str:
"""Validate an ``emotion`` tag for ``family`` (currently Orpheus only).
Raises if the family does not support emotion conditioning or the tag
is not in the per-family allowlist. ``family`` is canonicalised first
so callers don't have to.
"""
canonical_family = validate_tts_family(family)
if isinstance(emotion, bool):
raise TypeError(f"emotion must not be bool, got {emotion!r}")
if not isinstance(emotion, str):
raise TypeError(
f"emotion must be str, got {type(emotion).__name__}"
)
if not emotion:
raise ValueError("emotion must be non-empty")
if "\x00" in emotion:
raise ValueError("emotion must not contain null bytes")
if len(emotion) > _MAX_EMOTION_LEN:
raise ValueError(
f"emotion too long (max {_MAX_EMOTION_LEN} chars)"
)
spec = _TTS_FAMILY_METADATA[canonical_family]
if not spec.supports_emotion:
raise ValueError(
f"tts_family={canonical_family!r} does not support emotion "
"conditioning"
)
canonical = emotion.lower()
# Per-family allowlist — data-driven so future emotion-supporting
# families cannot silently bypass the check.
family_allowlist = _FAMILY_EMOTIONS.get(canonical_family)
if family_allowlist is not None and canonical not in family_allowlist:
allowed = ", ".join(sorted(family_allowlist))
raise ValueError(
f"emotion {emotion!r} not in {canonical_family} allowlist. "
f"Allowed: {allowed}"
)
return canonical
def validate_tts_compat(*, task: str, modality: str, backend: str) -> None:
"""Schema-time gate for ``task='tts'``.
Rejects:
- non-string / bool args (defence-in-depth sister-function bool
guards align with v0.30.0 ``Candidate`` policy).
- non-TTS task (intended for ``task == 'tts'``).
- ``modality != 'audio_out'`` (TTS is audio-output by definition).
- ``backend == 'mlx'`` (no MLX TTS path in v0.52.0).
"""
for name, value in (("task", task), ("modality", modality), ("backend", backend)):
if isinstance(value, bool):
raise TypeError(f"{name} must not be bool, got {value!r}")
if not isinstance(value, str) or not value:
raise ValueError(f"{name} must be a non-empty string")
if task != "tts":
raise ValueError(
f"validate_tts_compat called with task={task!r} (expected 'tts')"
)
if modality != "audio_out":
raise ValueError(
f"task='tts' requires modality='audio_out'; got modality={modality!r}"
)
if backend == "mlx":
raise ValueError(
"task='tts' is not supported on backend=mlx in v0.52.0"
)
def build_tts_trainer() -> None:
"""Live TTS trainer factory — deferred to v0.52.1.
Mirrors v0.50.0 ``build_prm_trainer`` / v0.49.0 ``apply_longlora_forward_override``
stub-then-live pattern.
"""
raise NotImplementedError(
"TTS trainer (task='tts') live wiring deferred to v0.52.1. "
"Schema accepts the value but no trainer wrapper is registered yet."
)

View File

@ -39,12 +39,12 @@ class TestExportFormatsExtended:
assert "gptq" in SUPPORTED_FORMATS
def test_format_count(self):
"""Should support exactly 5 export formats."""
assert len(SUPPORTED_FORMATS) == 5
"""v0.52.0 — 5 live formats + 2 BitNet stubs (bitnet, tq1_0)."""
assert len(SUPPORTED_FORMATS) == 7
def test_all_formats_present(self):
"""All five formats should be present."""
expected = {"gguf", "onnx", "tensorrt", "awq", "gptq"}
"""All seven formats should be present (v0.52.0 + bitnet/tq1_0 stubs)."""
expected = {"gguf", "onnx", "tensorrt", "awq", "gptq", "bitnet", "tq1_0"}
assert set(SUPPORTED_FORMATS) == expected

View File

@ -21,8 +21,10 @@ class TestExportFormats:
assert "tensorrt" in SUPPORTED_FORMATS
def test_format_count(self):
"""Should support exactly 5 export formats (gguf, onnx, tensorrt, awq, gptq)."""
assert len(SUPPORTED_FORMATS) == 5
"""v0.52.0 — 5 live formats (gguf/onnx/tensorrt/awq/gptq) + 2 BitNet stubs."""
assert len(SUPPORTED_FORMATS) == 7
assert "bitnet" in SUPPORTED_FORMATS
assert "tq1_0" in SUPPORTED_FORMATS
# ─── ONNX Export CLI Tests ──────────────────────────────────────────────

View File

@ -260,17 +260,18 @@ class TestV025NewRecipes:
assert cfg.base == recipe.model
assert cfg.task == recipe.task
def test_catalog_size_is_106(self):
def test_catalog_size_is_112(self):
"""Total catalog size — grew with each release.
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.
v0.51.0 added 26 (model catalog expansion) -> 106.
v0.52.0 added 6 (5 TTS + Falcon-E BitNet) -> 112.
"""
from soup_cli.recipes.catalog import RECIPES
assert len(RECIPES) == 106
assert len(RECIPES) == 112
def test_new_recipes_searchable(self):
"""Search returns the new recipes via keyword/task filter."""

1199
tests/test_v0520.py Normal file

File diff suppressed because it is too large Load Diff