From df7f49feda7ec2795c97aa7a7ad8386d1fb61265 Mon Sep 17 00:00:00 2001 From: Alpamys Date: Tue, 12 May 2026 13:38:01 +0500 Subject: [PATCH] =?UTF-8?q?feat(v0.52.0):=20Modality=20II=20=E2=80=94=20TT?= =?UTF-8?q?S=20+=20Distillation=20+=20BitNet=20+=20EBFT-GDPO=20+=20MoE=20q?= =?UTF-8?q?uant=20+=20reasoning=5Feffort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CONTRIBUTING.md | 3 +- README.md | 110 ++- SECURITY.md | 4 +- pyproject.toml | 2 +- soup_cli/__init__.py | 2 +- soup_cli/commands/export.py | 32 +- soup_cli/config/schema.py | 501 +++++++++++- soup_cli/recipes/catalog.py | 164 ++++ soup_cli/utils/bitnet.py | 173 ++++ soup_cli/utils/classifier.py | 168 ++++ soup_cli/utils/distill.py | 200 +++++ soup_cli/utils/ebft_gdpo.py | 191 +++++ soup_cli/utils/moe_quant.py | 141 ++++ soup_cli/utils/reasoning_effort.py | 37 + soup_cli/utils/tts.py | 214 +++++ tests/test_awq_gptq_export.py | 8 +- tests/test_onnx_tensorrt_export.py | 6 +- tests/test_recipes.py | 5 +- tests/test_v0520.py | 1199 ++++++++++++++++++++++++++++ 19 files changed, 3135 insertions(+), 25 deletions(-) create mode 100644 soup_cli/utils/bitnet.py create mode 100644 soup_cli/utils/classifier.py create mode 100644 soup_cli/utils/distill.py create mode 100644 soup_cli/utils/ebft_gdpo.py create mode 100644 soup_cli/utils/moe_quant.py create mode 100644 soup_cli/utils/reasoning_effort.py create mode 100644 soup_cli/utils/tts.py create mode 100644 tests/test_v0520.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abb1e85..aff158a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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.0–v0.35.0 + v0.47.0 + v0.48.0 + v0.49.0 + v0.50.0 only; full per-release table lives in `.claude/CLAUDE.md`.) diff --git a/README.md b/README.md index f7b2758..1404c21 100644 --- a/README.md +++ b/README.md @@ -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 `. 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 `. 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. diff --git a/SECURITY.md b/SECURITY.md index 92559f7..1a19ddb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 `, 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-`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 `. (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) diff --git a/pyproject.toml b/pyproject.toml index 3d11bed..07f1062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/soup_cli/__init__.py b/soup_cli/__init__.py index 5bfc1be..c70d9ad 100644 --- a/soup_cli/__init__.py +++ b/soup_cli/__init__.py @@ -1,3 +1,3 @@ """Soup CLI — Fine-tune LLMs in one command.""" -__version__ = "0.51.0" +__version__ = "0.52.0" diff --git a/soup_cli/commands/export.py b/soup_cli/commands/export.py index 3ba8843..f311f28 100644 --- a/soup_cli/commands/export.py +++ b/soup_cli/commands/export.py @@ -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" diff --git a/soup_cli/config/schema.py b/soup_cli/config/schema.py index fd5ea9f..d3d63ac 100644 --- a/soup_cli/config/schema.py +++ b/soup_cli/config/schema.py @@ -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 diff --git a/soup_cli/recipes/catalog.py b/soup_cli/recipes/catalog.py index 572b245..ccf941b 100644 --- a/soup_cli/recipes/catalog.py +++ b/soup_cli/recipes/catalog.py @@ -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 """, ), diff --git a/soup_cli/utils/bitnet.py b/soup_cli/utils/bitnet.py new file mode 100644 index 0000000..65e5faa --- /dev/null +++ b/soup_cli/utils/bitnet.py @@ -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." + ) diff --git a/soup_cli/utils/classifier.py b/soup_cli/utils/classifier.py new file mode 100644 index 0000000..ccfebf1 --- /dev/null +++ b/soup_cli/utils/classifier.py @@ -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." + ) diff --git a/soup_cli/utils/distill.py b/soup_cli/utils/distill.py new file mode 100644 index 0000000..cf7de89 --- /dev/null +++ b/soup_cli/utils/distill.py @@ -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." + ) diff --git a/soup_cli/utils/ebft_gdpo.py b/soup_cli/utils/ebft_gdpo.py new file mode 100644 index 0000000..71fdc91 --- /dev/null +++ b/soup_cli/utils/ebft_gdpo.py @@ -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." + ) diff --git a/soup_cli/utils/moe_quant.py b/soup_cli/utils/moe_quant.py new file mode 100644 index 0000000..a09b54f --- /dev/null +++ b/soup_cli/utils/moe_quant.py @@ -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." + ) diff --git a/soup_cli/utils/reasoning_effort.py b/soup_cli/utils/reasoning_effort.py new file mode 100644 index 0000000..a144398 --- /dev/null +++ b/soup_cli/utils/reasoning_effort.py @@ -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 diff --git a/soup_cli/utils/tts.py b/soup_cli/utils/tts.py new file mode 100644 index 0000000..5f78093 --- /dev/null +++ b/soup_cli/utils/tts.py @@ -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." + ) diff --git a/tests/test_awq_gptq_export.py b/tests/test_awq_gptq_export.py index d58e185..3afc9f8 100644 --- a/tests/test_awq_gptq_export.py +++ b/tests/test_awq_gptq_export.py @@ -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 diff --git a/tests/test_onnx_tensorrt_export.py b/tests/test_onnx_tensorrt_export.py index 525059e..a30974a 100644 --- a/tests/test_onnx_tensorrt_export.py +++ b/tests/test_onnx_tensorrt_export.py @@ -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 ────────────────────────────────────────────── diff --git a/tests/test_recipes.py b/tests/test_recipes.py index 0d9ed27..39ee50e 100644 --- a/tests/test_recipes.py +++ b/tests/test_recipes.py @@ -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.""" diff --git a/tests/test_v0520.py b/tests/test_v0520.py new file mode 100644 index 0000000..c428085 --- /dev/null +++ b/tests/test_v0520.py @@ -0,0 +1,1199 @@ +"""v0.52.0 Modality II — TTS + Distillation + BitNet + EBFT + GDPO + MoE + reasoning_effort. + +Schema-only test suite. Live wiring deferred to v0.52.1. Mirrors v0.50.0 / +v0.51.0 single-file test layout (test_v0500_part_X.py was per-Part; v0.51.0 +collapsed into one test_v0510.py; v0.52.0 follows v0.51.0). +""" + +from __future__ import annotations + +import math +from types import MappingProxyType + +import pytest +import typer + +from soup_cli.config.loader import load_config_from_string + +# --------------------------------------------------------------------------- +# Part A — TTS +# --------------------------------------------------------------------------- + + +class TestTTSUtils: + def test_supported_families_frozenset(self): + from soup_cli.utils.tts import SUPPORTED_TTS_FAMILIES + + assert isinstance(SUPPORTED_TTS_FAMILIES, frozenset) + assert SUPPORTED_TTS_FAMILIES == { + "orpheus", "sesame_csm", "llasa", "spark", "oute", + } + + @pytest.mark.parametrize( + "name", ["orpheus", "ORPHEUS", "Sesame_CSM", "llasa", "spark", "oute"], + ) + def test_validate_family_canonical(self, name): + from soup_cli.utils.tts import validate_tts_family + + assert validate_tts_family(name) == name.lower() + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (123, TypeError), + ("", ValueError), + ("orph\x00eus", ValueError), + ("x" * 100, ValueError), + ("unknown_family", ValueError), + ], + ) + def test_validate_family_rejects(self, bad, exc): + from soup_cli.utils.tts import validate_tts_family + + with pytest.raises(exc): + validate_tts_family(bad) + + def test_family_metadata_frozen(self): + from soup_cli.utils.tts import get_tts_family_spec + + spec = get_tts_family_spec("orpheus") + with pytest.raises(Exception): + spec.name = "evil" # type: ignore[misc] + + def test_supports_emotion(self): + from soup_cli.utils.tts import family_supports_emotion + + assert family_supports_emotion("orpheus") is True + assert family_supports_emotion("oute") is True + assert family_supports_emotion("llasa") is False + + def test_emotion_tag_orpheus_happy(self): + from soup_cli.utils.tts import validate_emotion_tag + + assert validate_emotion_tag("happy", family="orpheus") == "happy" + assert validate_emotion_tag("HAPPY", family="orpheus") == "happy" + + def test_emotion_tag_orpheus_unknown(self): + from soup_cli.utils.tts import validate_emotion_tag + + with pytest.raises(ValueError, match="orpheus allowlist"): + validate_emotion_tag("euphoric", family="orpheus") + + def test_emotion_tag_unsupported_family(self): + from soup_cli.utils.tts import validate_emotion_tag + + with pytest.raises(ValueError, match="does not support emotion"): + validate_emotion_tag("happy", family="llasa") + + def test_emotion_tag_bool_rejected(self): + from soup_cli.utils.tts import validate_emotion_tag + + with pytest.raises(TypeError): + validate_emotion_tag(True, family="orpheus") + + def test_validate_tts_compat_happy(self): + from soup_cli.utils.tts import validate_tts_compat + + validate_tts_compat(task="tts", modality="audio_out", backend="transformers") + + @pytest.mark.parametrize( + "kwargs,match", [ + ({"task": "sft", "modality": "audio_out", "backend": "transformers"}, "tts"), + ({"task": "tts", "modality": "text", "backend": "transformers"}, "audio_out"), + ({"task": "tts", "modality": "audio_out", "backend": "mlx"}, "mlx"), + ], + ) + def test_validate_tts_compat_rejects(self, kwargs, match): + from soup_cli.utils.tts import validate_tts_compat + + with pytest.raises(ValueError, match=match): + validate_tts_compat(**kwargs) + + def test_build_tts_trainer_deferred(self): + from soup_cli.utils.tts import build_tts_trainer + + with pytest.raises(NotImplementedError, match="v0.52.1"): + build_tts_trainer() + + +class TestTTSSchemaIntegration: + def test_task_tts_happy(self): + yaml = """ +base: canopylabs/orpheus-tts +task: tts +modality: audio_out +data: {train: ./d.jsonl} +training: + tts_family: orpheus + tts_emotion: happy +""" + cfg = load_config_from_string(yaml) + assert cfg.task == "tts" + assert cfg.modality == "audio_out" + assert cfg.training.tts_family == "orpheus" + assert cfg.training.tts_emotion == "happy" + + def test_task_tts_without_family_rejected(self): + yaml = """ +base: foo +task: tts +modality: audio_out +data: {train: ./d.jsonl} +""" + with pytest.raises(Exception, match="tts_family"): + load_config_from_string(yaml) + + def test_tts_family_without_task_rejected(self): + yaml = """ +base: foo +task: sft +data: {train: ./d.jsonl} +training: + tts_family: orpheus +""" + with pytest.raises(Exception, match="tts"): + load_config_from_string(yaml) + + def test_tts_emotion_unsupported_family_rejected(self): + yaml = """ +base: foo +task: tts +modality: audio_out +data: {train: ./d.jsonl} +training: + tts_family: llasa + tts_emotion: happy +""" + with pytest.raises(Exception, match="does not support emotion"): + load_config_from_string(yaml) + + def test_audio_out_modality_accepted(self): + # audio_out paired with non-TTS task still loads (modality alone + # doesn't force task='tts'); but in practice only TTS uses it. + # Defence-in-depth: test the Literal accepts it. + yaml = """ +base: foo +task: sft +modality: audio_out +data: {train: ./d.jsonl, format: audio} +""" + cfg = load_config_from_string(yaml) + assert cfg.modality == "audio_out" + + +# --------------------------------------------------------------------------- +# Part B — classifier / reranker / cross_encoder +# --------------------------------------------------------------------------- + + +class TestClassifierUtils: + def test_classifier_tasks_frozenset(self): + from soup_cli.utils.classifier import CLASSIFIER_TASKS + + assert CLASSIFIER_TASKS == {"classifier", "reranker", "cross_encoder"} + + @pytest.mark.parametrize("task", ["classifier", "reranker", "cross_encoder"]) + def test_is_classifier_task_true(self, task): + from soup_cli.utils.classifier import is_classifier_task + + assert is_classifier_task(task) is True + + @pytest.mark.parametrize("task", ["sft", "dpo", "", True, 123, None]) + def test_is_classifier_task_false(self, task): + from soup_cli.utils.classifier import is_classifier_task + + assert is_classifier_task(task) is False + + def test_get_classifier_spec_paired_input(self): + from soup_cli.utils.classifier import get_classifier_spec + + assert get_classifier_spec("cross_encoder").paired_input is True + assert get_classifier_spec("classifier").paired_input is False + + def test_get_classifier_spec_unknown(self): + from soup_cli.utils.classifier import get_classifier_spec + + with pytest.raises(ValueError, match="classifier task"): + get_classifier_spec("sft") + + @pytest.mark.parametrize( + "value,exc", [ + (True, TypeError), + ("3", TypeError), + (None, TypeError), + (0, ValueError), + (-1, ValueError), + (2000, ValueError), + ], + ) + def test_validate_num_labels_rejects(self, value, exc): + from soup_cli.utils.classifier import validate_num_labels + + with pytest.raises(exc): + validate_num_labels(value) + + def test_validate_num_labels_happy(self): + from soup_cli.utils.classifier import validate_num_labels + + assert validate_num_labels(3) == 3 + assert validate_num_labels(1024) == 1024 + + def test_validate_label_names_dedup(self): + from soup_cli.utils.classifier import validate_label_names + + with pytest.raises(ValueError, match="unique"): + validate_label_names(["a", "a", "b"]) + + @pytest.mark.parametrize( + "value,exc", [ + ("a", TypeError), + ([True, "a"], TypeError), + ([""], ValueError), + (["x\x00"], ValueError), + (["x" * 200], ValueError), + ], + ) + def test_validate_label_names_rejects(self, value, exc): + from soup_cli.utils.classifier import validate_label_names + + with pytest.raises(exc): + validate_label_names(value) + + def test_validate_label_names_defensive_copy(self): + from soup_cli.utils.classifier import validate_label_names + + src = ["a", "b"] + out = validate_label_names(src) + assert out is not src + + def test_validate_classifier_compat_mlx_reject(self): + from soup_cli.utils.classifier import validate_classifier_compat + + with pytest.raises(ValueError, match="mlx"): + validate_classifier_compat( + task="classifier", backend="mlx", modality="text", + ) + + def test_validate_classifier_compat_non_text(self): + from soup_cli.utils.classifier import validate_classifier_compat + + with pytest.raises(ValueError, match="text"): + validate_classifier_compat( + task="reranker", backend="transformers", modality="vision", + ) + + def test_build_classifier_trainer_deferred(self): + from soup_cli.utils.classifier import build_classifier_trainer + + with pytest.raises(NotImplementedError, match="v0.52.1"): + build_classifier_trainer() + + +class TestClassifierSchema: + def test_classifier_happy(self): + cfg = load_config_from_string( + "base: foo\ntask: classifier\ndata: {train: ./d.jsonl}\n" + "training: {num_labels: 5, classifier_kind: single_label}\n" + ) + assert cfg.task == "classifier" + assert cfg.training.num_labels == 5 + + @pytest.mark.parametrize("task", ["reranker", "cross_encoder"]) + def test_reranker_and_cross_encoder_happy(self, task): + cfg = load_config_from_string( + f"base: foo\ntask: {task}\ndata: {{train: ./d.jsonl}}\n" + "training: {num_labels: 1}\n" + ) + assert cfg.task == task + + def test_classifier_label_names_mismatch_rejected(self): + yaml = ( + "base: foo\ntask: classifier\ndata: {train: ./d.jsonl}\n" + "training:\n num_labels: 3\n label_names: [a, b]\n" + ) + with pytest.raises(Exception, match="num_labels"): + load_config_from_string(yaml) + + def test_num_labels_outside_classifier_rejected(self): + yaml = ( + "base: foo\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {num_labels: 3}\n" + ) + with pytest.raises(Exception, match="classifier"): + load_config_from_string(yaml) + + +# --------------------------------------------------------------------------- +# Part C — distillation +# --------------------------------------------------------------------------- + + +class TestDistillUtils: + def test_divergence_canonical(self): + from soup_cli.utils.distill import validate_divergence + + assert validate_divergence("kl") == "forward_kl" + assert validate_divergence("KL") == "forward_kl" + assert validate_divergence("forward_kl") == "forward_kl" + assert validate_divergence("reverse_kl") == "reverse_kl" + assert validate_divergence("js") == "js" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("kl\x00", ValueError), + ("k" * 100, ValueError), + ("unknown", ValueError), + ], + ) + def test_validate_divergence_rejects(self, bad, exc): + from soup_cli.utils.distill import validate_divergence + + with pytest.raises(exc): + validate_divergence(bad) + + def test_get_divergence_spec_symmetric(self): + from soup_cli.utils.distill import get_divergence_spec + + assert get_divergence_spec("js").symmetric is True + assert get_divergence_spec("forward_kl").symmetric is False + + @pytest.mark.parametrize( + "value,exc", [ + (True, TypeError), + ("1.0", TypeError), + (float("nan"), ValueError), + (float("inf"), ValueError), + (0.0, ValueError), + (0.01, ValueError), + (101.0, ValueError), + ], + ) + def test_validate_distill_temperature_rejects(self, value, exc): + from soup_cli.utils.distill import validate_distill_temperature + + with pytest.raises(exc): + validate_distill_temperature(value) + + def test_validate_distill_temperature_happy(self): + from soup_cli.utils.distill import validate_distill_temperature + + assert validate_distill_temperature(2.0) == 2.0 + assert validate_distill_temperature(0.05) == 0.05 + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (123, TypeError), + ("", ValueError), + ("x\x00", ValueError), + ("x" * 1000, ValueError), + ], + ) + def test_validate_teacher_model_rejects(self, bad, exc): + from soup_cli.utils.distill import validate_teacher_model + + with pytest.raises(exc): + validate_teacher_model(bad) + + def test_validate_distill_compat_no_teacher(self): + from soup_cli.utils.distill import validate_distill_compat + + with pytest.raises(ValueError, match="teacher_model"): + validate_distill_compat( + task="distill", backend="transformers", teacher_model=None, + ) + + def test_validate_distill_compat_mlx(self): + from soup_cli.utils.distill import validate_distill_compat + + with pytest.raises(ValueError, match="mlx"): + validate_distill_compat( + task="distill", backend="mlx", teacher_model="t/model", + ) + + def test_build_distill_trainer_deferred(self): + from soup_cli.utils.distill import build_distill_trainer + + with pytest.raises(NotImplementedError, match="v0.52.1"): + build_distill_trainer() + + +class TestDistillSchema: + def test_distill_happy(self): + yaml = ( + "base: foo\ntask: distill\ndata: {train: ./d.jsonl}\n" + "training:\n teacher_model: meta-llama/Llama-3.1-70B\n" + " distill_divergence: reverse_kl\n distill_temperature: 2.5\n" + ) + cfg = load_config_from_string(yaml) + assert cfg.task == "distill" + assert cfg.training.distill_divergence == "reverse_kl" + assert cfg.training.distill_temperature == 2.5 + + def test_kl_alias_canonicalised(self): + yaml = ( + "base: foo\ntask: distill\ndata: {train: ./d.jsonl}\n" + "training:\n teacher_model: t/m\n distill_divergence: kl\n" + ) + cfg = load_config_from_string(yaml) + assert cfg.training.distill_divergence == "forward_kl" + + def test_teacher_outside_distill_rejected(self): + yaml = ( + "base: foo\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {teacher_model: t/m}\n" + ) + with pytest.raises(Exception, match="distill"): + load_config_from_string(yaml) + + def test_distill_temperature_nan_rejected(self): + yaml = ( + "base: foo\ntask: distill\ndata: {train: ./d.jsonl}\n" + "training:\n teacher_model: t/m\n distill_temperature: .nan\n" + ) + with pytest.raises(Exception, match="finite"): + load_config_from_string(yaml) + + +# --------------------------------------------------------------------------- +# Part D — BitNet 1.58 +# --------------------------------------------------------------------------- + + +class TestBitNetUtils: + def test_bitnet_quant_formats_frozenset(self): + from soup_cli.utils.bitnet import BITNET_EXPORT_FORMATS, BITNET_QUANT_FORMATS + + assert isinstance(BITNET_QUANT_FORMATS, frozenset) + assert isinstance(BITNET_EXPORT_FORMATS, frozenset) + assert BITNET_QUANT_FORMATS == {"bitnet_1.58"} + assert BITNET_EXPORT_FORMATS == {"bitnet", "tq1_0"} + + @pytest.mark.parametrize( + "value,expected", [ + ("bitnet_1.58", True), + ("4bit", False), + ("", False), + (None, False), + (True, False), + (123, False), + ], + ) + def test_is_bitnet_quant(self, value, expected): + from soup_cli.utils.bitnet import is_bitnet_quant + + assert is_bitnet_quant(value) is expected + + def test_get_bitnet_spec(self): + from soup_cli.utils.bitnet import get_bitnet_spec + + spec = get_bitnet_spec("bitnet_1.58") + assert spec.bits == 1.58 + assert spec.live_wired is False + + def test_get_bitnet_spec_unknown(self): + from soup_cli.utils.bitnet import get_bitnet_spec + + with pytest.raises(ValueError, match="bitnet"): + get_bitnet_spec("4bit") + + @pytest.mark.parametrize( + "name,expected", [ + ("microsoft/bitnet-b1.58-2B", True), + ("tiiuae/Falcon-E-1B-Instruct", True), + ("1bitllm/foo", True), + ("OneBitLLM/falcon-e", True), + ("meta-llama/Llama-3.1-8B", False), + ("", False), + (None, False), + (True, False), + ("evil\x00", False), + ], + ) + def test_is_bitnet_model(self, name, expected): + from soup_cli.utils.bitnet import is_bitnet_model + + assert is_bitnet_model(name) is expected + + def test_validate_bitnet_compat_mlx_reject(self): + from soup_cli.utils.bitnet import validate_bitnet_compat + + with pytest.raises(ValueError, match="mlx"): + validate_bitnet_compat(task="sft", backend="mlx", modality="text") + + def test_validate_bitnet_compat_vision_reject(self): + from soup_cli.utils.bitnet import validate_bitnet_compat + + with pytest.raises(ValueError, match="text"): + validate_bitnet_compat( + task="sft", backend="transformers", modality="vision", + ) + + def test_validate_bitnet_compat_grpo_reject(self): + from soup_cli.utils.bitnet import validate_bitnet_compat + + with pytest.raises(ValueError, match="task"): + validate_bitnet_compat( + task="grpo", backend="transformers", modality="text", + ) + + def test_validate_bitnet_export_canonical(self): + from soup_cli.utils.bitnet import validate_bitnet_export + + assert validate_bitnet_export("bitnet") == "bitnet" + assert validate_bitnet_export("TQ1_0") == "tq1_0" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("foo\x00", ValueError), + ("Q4_K_M", ValueError), + ], + ) + def test_validate_bitnet_export_rejects(self, bad, exc): + from soup_cli.utils.bitnet import validate_bitnet_export + + with pytest.raises(exc): + validate_bitnet_export(bad) + + def test_build_bitnet_trainer_deferred(self): + from soup_cli.utils.bitnet import build_bitnet_trainer + + with pytest.raises(NotImplementedError, match="v0.52.1"): + build_bitnet_trainer() + + def test_export_bitnet_gguf_deferred(self): + from soup_cli.utils.bitnet import export_bitnet_gguf + + with pytest.raises(NotImplementedError, match="v0.52.1"): + export_bitnet_gguf() + + +class TestBitNetSchema: + def test_bitnet_sft_happy(self): + cfg = load_config_from_string( + "base: tiiuae/Falcon-E-1B-Instruct\ntask: sft\n" + "data: {train: ./d.jsonl}\ntraining: {quantization: bitnet_1.58}\n" + ) + assert cfg.training.quantization == "bitnet_1.58" + + def test_bitnet_dpo_happy(self): + cfg = load_config_from_string( + "base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n" + "training: {quantization: bitnet_1.58}\n" + ) + assert cfg.training.quantization == "bitnet_1.58" + + def test_bitnet_grpo_rejected(self): + yaml = ( + "base: x\ntask: grpo\ndata: {train: ./d.jsonl}\n" + "training: {quantization: bitnet_1.58, reward_fn: accuracy, num_generations: 4}\n" + ) + with pytest.raises(Exception, match="task"): + load_config_from_string(yaml) + + def test_bitnet_mlx_rejected(self): + yaml = ( + "base: x\ntask: sft\nbackend: mlx\ndata: {train: ./d.jsonl}\n" + "training: {quantization: bitnet_1.58}\n" + ) + with pytest.raises(Exception, match="mlx"): + load_config_from_string(yaml) + + +# --------------------------------------------------------------------------- +# Part E — EBFT + GDPO +# --------------------------------------------------------------------------- + + +class TestEbftGdpoUtils: + def test_ebft_variants(self): + from soup_cli.utils.ebft_gdpo import EBFT_VARIANTS, validate_ebft_variant + + assert EBFT_VARIANTS == {"structured", "strided"} + assert validate_ebft_variant("structured") == "structured" + assert validate_ebft_variant("STRIDED") == "strided" + + def test_gdpo_variants(self): + from soup_cli.utils.ebft_gdpo import GDPO_VARIANTS, validate_gdpo_variant + + assert GDPO_VARIANTS == {"standard", "length_normalized", "margin"} + assert validate_gdpo_variant("Margin") == "margin" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("foo\x00", ValueError), + ("unknown", ValueError), + ], + ) + def test_validate_ebft_variant_rejects(self, bad, exc): + from soup_cli.utils.ebft_gdpo import validate_ebft_variant + + with pytest.raises(exc): + validate_ebft_variant(bad) + + def test_ebft_temperature_bounds(self): + from soup_cli.utils.ebft_gdpo import validate_ebft_temperature + + assert validate_ebft_temperature(1.0) == 1.0 + for bad in (True, float("nan"), float("inf"), 0.0, 1000.0): + with pytest.raises((TypeError, ValueError)): + validate_ebft_temperature(bad) + + def test_validate_ebft_compat_dpo_rejected(self): + from soup_cli.utils.ebft_gdpo import validate_ebft_compat + + with pytest.raises(ValueError, match="sft"): + validate_ebft_compat(task="dpo", backend="transformers") + + def test_validate_gdpo_compat_sft_rejected(self): + from soup_cli.utils.ebft_gdpo import validate_gdpo_compat + + with pytest.raises(ValueError, match="dpo"): + validate_gdpo_compat(task="sft", backend="transformers") + + def test_get_ebft_spec(self): + from soup_cli.utils.ebft_gdpo import get_ebft_spec, get_gdpo_spec + + assert get_ebft_spec("structured").live_wired is False + assert get_gdpo_spec("margin").live_wired is False + + def test_apply_ebft_loss_deferred(self): + from soup_cli.utils.ebft_gdpo import apply_ebft_loss, apply_gdpo_loss + + with pytest.raises(NotImplementedError, match="v0.52.1"): + apply_ebft_loss() + with pytest.raises(NotImplementedError, match="v0.52.1"): + apply_gdpo_loss() + + +class TestEbftGdpoSchema: + def test_ebft_happy(self): + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {ebft_variant: structured, ebft_temperature: 1.0}\n" + ) + assert cfg.training.ebft_variant == "structured" + assert cfg.training.ebft_temperature == 1.0 + + def test_ebft_temp_requires_variant(self): + yaml = ( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {ebft_temperature: 2.0}\n" + ) + with pytest.raises(Exception, match="ebft_variant"): + load_config_from_string(yaml) + + def test_ebft_on_dpo_rejected(self): + yaml = ( + "base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n" + "training: {ebft_variant: strided}\n" + ) + with pytest.raises(Exception, match="sft"): + load_config_from_string(yaml) + + def test_gdpo_dpo_happy(self): + cfg = load_config_from_string( + "base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n" + "training: {gdpo_variant: length_normalized}\n" + ) + assert cfg.training.gdpo_variant == "length_normalized" + + def test_gdpo_on_sft_rejected(self): + yaml = ( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {gdpo_variant: standard}\n" + ) + with pytest.raises(Exception, match="dpo"): + load_config_from_string(yaml) + + +# --------------------------------------------------------------------------- +# Part F — MoE expert quant + train_router_only +# --------------------------------------------------------------------------- + + +class TestMoeQuantUtils: + def test_moe_expert_quant_formats(self): + from soup_cli.utils.moe_quant import ( + MOE_EXPERT_QUANT_FORMATS, + validate_moe_expert_quant, + ) + + assert MOE_EXPERT_QUANT_FORMATS == {"nf4", "int8_rowwise"} + assert validate_moe_expert_quant("NF4") == "nf4" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("foo\x00", ValueError), + ("unknown", ValueError), + ], + ) + def test_validate_moe_expert_quant_rejects(self, bad, exc): + from soup_cli.utils.moe_quant import validate_moe_expert_quant + + with pytest.raises(exc): + validate_moe_expert_quant(bad) + + def test_get_moe_expert_quant_spec_bits(self): + from soup_cli.utils.moe_quant import get_moe_expert_quant_spec + + assert get_moe_expert_quant_spec("nf4").bits == 4 + assert get_moe_expert_quant_spec("int8_rowwise").bits == 8 + + def test_moe_expert_quant_requires_moe_lora(self): + from soup_cli.utils.moe_quant import validate_moe_expert_quant_compat + + with pytest.raises(ValueError, match="moe_lora"): + validate_moe_expert_quant_compat( + backend="transformers", moe_lora=False, + ) + + def test_train_router_only_requires_moe_lora(self): + from soup_cli.utils.moe_quant import validate_train_router_only_compat + + with pytest.raises(ValueError, match="moe_lora"): + validate_train_router_only_compat( + backend="transformers", moe_lora=False, + ) + + def test_validate_moe_expert_quant_compat_mlx(self): + from soup_cli.utils.moe_quant import validate_moe_expert_quant_compat + + with pytest.raises(ValueError, match="mlx"): + validate_moe_expert_quant_compat(backend="mlx", moe_lora=True) + + def test_apply_moe_expert_quant_deferred(self): + from soup_cli.utils.moe_quant import apply_moe_expert_quant + + with pytest.raises(NotImplementedError, match="v0.52.1"): + apply_moe_expert_quant() + + +class TestMoeQuantSchema: + def test_moe_expert_quant_happy(self): + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {moe_lora: true, moe_expert_quant: nf4}\n" + ) + assert cfg.training.moe_expert_quant == "nf4" + + def test_moe_expert_quant_without_moe_lora_rejected(self): + yaml = ( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {moe_expert_quant: nf4}\n" + ) + with pytest.raises(Exception, match="moe_lora"): + load_config_from_string(yaml) + + def test_train_router_only_happy(self): + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {moe_lora: true, train_router_only: true}\n" + ) + assert cfg.training.train_router_only is True + + def test_train_router_only_without_moe_lora_rejected(self): + yaml = ( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {train_router_only: true}\n" + ) + with pytest.raises(Exception, match="moe_lora"): + load_config_from_string(yaml) + + +# --------------------------------------------------------------------------- +# Part G — reasoning_effort + train_on_eot +# --------------------------------------------------------------------------- + + +class TestReasoningEffortUtils: + def test_levels(self): + from soup_cli.utils.reasoning_effort import ( + REASONING_EFFORT_LEVELS, + validate_reasoning_effort, + ) + + assert REASONING_EFFORT_LEVELS == {"low", "medium", "high"} + assert validate_reasoning_effort("LOW") == "low" + assert validate_reasoning_effort("medium") == "medium" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("x\x00", ValueError), + ("ultra", ValueError), + ], + ) + def test_validate_reasoning_effort_rejects(self, bad, exc): + from soup_cli.utils.reasoning_effort import validate_reasoning_effort + + with pytest.raises(exc): + validate_reasoning_effort(bad) + + +class TestReasoningEffortSchema: + @pytest.mark.parametrize("level", ["low", "medium", "high"]) + def test_reasoning_effort_happy(self, level): + cfg = load_config_from_string( + f"base: openai/gpt-oss-20b\ntask: sft\ndata: {{train: ./d.jsonl}}\n" + f"training: {{reasoning_effort: {level}}}\n" + ) + assert cfg.training.reasoning_effort == level + + def test_train_on_eot_default_false(self): + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + ) + assert cfg.training.train_on_eot is False + + def test_train_on_eot_true(self): + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {train_on_eot: true}\n" + ) + assert cfg.training.train_on_eot is True + + +# --------------------------------------------------------------------------- +# Cross-cutting: immutability + module surface +# --------------------------------------------------------------------------- + + +class TestModuleSurface: + def test_tts_metadata_mapping_proxy(self): + from soup_cli.utils.tts import _TTS_FAMILY_METADATA # type: ignore + + assert isinstance(_TTS_FAMILY_METADATA, MappingProxyType) + + def test_classifier_metadata_mapping_proxy(self): + from soup_cli.utils.classifier import _CLASSIFIER_METADATA # type: ignore + + assert isinstance(_CLASSIFIER_METADATA, MappingProxyType) + + def test_distill_metadata_mapping_proxy(self): + from soup_cli.utils.distill import ( # type: ignore + _DIVERGENCE_ALIASES, + _DIVERGENCE_METADATA, + ) + + assert isinstance(_DIVERGENCE_METADATA, MappingProxyType) + assert isinstance(_DIVERGENCE_ALIASES, MappingProxyType) + + def test_bitnet_metadata_mapping_proxy(self): + from soup_cli.utils.bitnet import _BITNET_METADATA # type: ignore + + assert isinstance(_BITNET_METADATA, MappingProxyType) + + def test_ebft_gdpo_metadata_mapping_proxy(self): + from soup_cli.utils.ebft_gdpo import ( # type: ignore + _EBFT_METADATA, + _GDPO_METADATA, + ) + + assert isinstance(_EBFT_METADATA, MappingProxyType) + assert isinstance(_GDPO_METADATA, MappingProxyType) + + def test_moe_quant_metadata_mapping_proxy(self): + from soup_cli.utils.moe_quant import ( # type: ignore + _MOE_EXPERT_QUANT_METADATA, + ) + + assert isinstance(_MOE_EXPERT_QUANT_METADATA, MappingProxyType) + + +class TestV0520Recipes: + NEW_RECIPES = ( + "orpheus-tts-sft", + "sesame-csm-tts", + "llasa-tts", + "spark-tts", + "oute-tts", + "falcon-e-bitnet-sft", + ) + + @pytest.mark.parametrize("name", NEW_RECIPES) + def test_recipe_loads(self, name): + from soup_cli.recipes.catalog import RECIPES + + recipe = RECIPES[name] + cfg = load_config_from_string(recipe.yaml_str) + assert cfg.base == recipe.model + + @pytest.mark.parametrize( + "name,expected_family", [ + ("orpheus-tts-sft", "orpheus"), + ("sesame-csm-tts", "sesame_csm"), + ("llasa-tts", "llasa"), + ("spark-tts", "spark"), + ("oute-tts", "oute"), + ], + ) + def test_tts_recipe_family(self, name, expected_family): + from soup_cli.recipes.catalog import RECIPES + + cfg = load_config_from_string(RECIPES[name].yaml_str) + assert cfg.training.tts_family == expected_family + assert cfg.task == "tts" + assert cfg.modality == "audio_out" + + def test_falcon_e_bitnet_quant(self): + from soup_cli.recipes.catalog import RECIPES + + cfg = load_config_from_string(RECIPES["falcon-e-bitnet-sft"].yaml_str) + assert cfg.training.quantization == "bitnet_1.58" + + def test_total_catalog_size_grew(self): + from soup_cli.recipes.catalog import RECIPES + + # v0.51.0 shipped 106; v0.52.0 adds 6 (5 TTS + Falcon-E BitNet). + assert len(RECIPES) >= 112 + + +class TestTddReviewGaps: + """v0.52.0 TDD-review-pass coverage of gaps surfaced after the first cut.""" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("foo\x00", ValueError), + ("x" * 33, ValueError), + ("unknown", ValueError), + ], + ) + def test_validate_ebft_variant_oversize_etc(self, bad, exc): + from soup_cli.utils.ebft_gdpo import validate_ebft_variant + + with pytest.raises(exc): + validate_ebft_variant(bad) + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("foo\x00", ValueError), + ("x" * 33, ValueError), + ("unknown", ValueError), + ], + ) + def test_validate_gdpo_variant_full_matrix(self, bad, exc): + from soup_cli.utils.ebft_gdpo import validate_gdpo_variant + + with pytest.raises(exc): + validate_gdpo_variant(bad) + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + ("1.0", TypeError), + (float("nan"), ValueError), + (float("inf"), ValueError), + (0.0, ValueError), + (1000.0, ValueError), + ], + ) + def test_validate_ebft_temperature_explicit_exc(self, bad, exc): + from soup_cli.utils.ebft_gdpo import validate_ebft_temperature + + with pytest.raises(exc): + validate_ebft_temperature(bad) + + @pytest.mark.parametrize( + "kwargs,exc", [ + ({"task": "", "modality": "audio_out", "backend": "transformers"}, ValueError), + ({"task": "tts\x00", "modality": "audio_out", "backend": "transformers"}, ValueError), + ({"task": True, "modality": "audio_out", "backend": "transformers"}, TypeError), + ({"task": "tts", "modality": "", "backend": "transformers"}, ValueError), + ({"task": "tts", "modality": "audio_out", "backend": ""}, ValueError), + ({"task": "tts", "modality": "audio_out", "backend": False}, TypeError), + ], + ) + def test_validate_tts_compat_input_guards(self, kwargs, exc): + from soup_cli.utils.tts import validate_tts_compat + + with pytest.raises(exc): + validate_tts_compat(**kwargs) + + @pytest.mark.parametrize("task", ["grpo", "pretrain", "ppo", "embedding", "tts"]) + def test_reasoning_effort_task_gate_full_matrix(self, task): + # ``pretrain`` is in the SFT-family allowlist so it should accept. + # All other non-SFT-family tasks must reject. + sft_family = {"sft", "pretrain", "distill", "classifier", "reranker", "cross_encoder"} + # Need backend / modality / data to be valid; for tts we also need family. + extra = "" + modality = "" + if task == "tts": + modality = "modality: audio_out\n" + extra = " tts_family: orpheus\n" + if task == "grpo": + extra = " reward_fn: accuracy\n num_generations: 4\n" + yaml = ( + f"base: x\ntask: {task}\n{modality}data: {{train: ./d.jsonl}}\n" + f"training:\n reasoning_effort: low\n{extra}" + ) + if task in sft_family: + cfg = load_config_from_string(yaml) + assert cfg.training.reasoning_effort == "low" + else: + with pytest.raises(Exception, match="reasoning_effort"): + load_config_from_string(yaml) + + def test_train_on_eot_int_one_is_bool(self): + # YAML "1" parses as int; Pydantic bool field coerces 0/1. + # This is the documented Pydantic behaviour we accept; the + # task-gate is what protects against silent no-op. + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {train_on_eot: 1}\n" + ) + assert cfg.training.train_on_eot is True + + def test_tts_recipe_model_id_no_null_or_whitespace(self): + # Drift guard mirroring tests/test_v0510.py model-id safety check. + from soup_cli.recipes.catalog import RECIPES + + new = ( + "orpheus-tts-sft", "sesame-csm-tts", "llasa-tts", + "spark-tts", "oute-tts", "falcon-e-bitnet-sft", + ) + for name in new: + base = RECIPES[name].model + assert base, f"{name}: model is empty" + assert "\x00" not in base, f"{name}: model contains null byte" + assert base.strip() == base, f"{name}: model has surrounding whitespace" + for part in base.split("/"): + assert part, f"{name}: model has empty path component" + + +class TestBitnetExportCli: + """v0.52.0 Part D — `soup export --format bitnet/tq1_0` stub CLI.""" + + def _runner(self): + from typer.testing import CliRunner + + from soup_cli.commands.export import export + + app = typer.Typer() + app.command()(export) + return CliRunner(), app + + def test_export_format_bitnet_lists_in_help(self): + from soup_cli.commands.export import SUPPORTED_FORMATS + + assert "bitnet" in SUPPORTED_FORMATS + assert "tq1_0" in SUPPORTED_FORMATS + + def test_export_format_bitnet_stub_exits_zero(self, tmp_path): + runner, app = self._runner() + model = tmp_path / "fake-model" + model.mkdir() + result = runner.invoke(app, ["--model", str(model), "--format", "bitnet"]) + assert result.exit_code == 0, (result.output, repr(result.exception)) + assert "bitnet" in result.output.lower() + assert "v0.52.1" in result.output + + def test_export_format_tq1_0_stub_exits_zero(self, tmp_path): + runner, app = self._runner() + model = tmp_path / "fake-model" + model.mkdir() + result = runner.invoke(app, ["--model", str(model), "--format", "tq1_0"]) + assert result.exit_code == 0, (result.output, repr(result.exception)) + assert "tq1_0" in result.output.lower() + + +class TestReviewFixes: + """Coverage of the review fixes applied between v0.52.0 first cut + ship.""" + + def test_num_labels_bool_rejected_at_schema(self): + # Pydantic ge=1 accepts True (subclass of int); the explicit + # field_validator(mode="before") rejects bool. + yaml = ( + "base: x\ntask: classifier\ndata: {train: ./d.jsonl}\n" + "training: {num_labels: true}\n" + ) + with pytest.raises(Exception, match="num_labels"): + load_config_from_string(yaml) + + def test_reasoning_effort_canonicalised_via_validator(self): + cfg = load_config_from_string( + "base: x\ntask: sft\ndata: {train: ./d.jsonl}\n" + "training: {reasoning_effort: HIGH}\n" + ) + assert cfg.training.reasoning_effort == "high" + + def test_reasoning_effort_task_gate(self): + yaml = ( + "base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n" + "training: {reasoning_effort: high}\n" + ) + with pytest.raises(Exception, match="reasoning_effort"): + load_config_from_string(yaml) + + def test_train_on_eot_task_gate(self): + yaml = ( + "base: x\ntask: dpo\ndata: {train: ./d.jsonl}\n" + "training: {train_on_eot: true}\n" + ) + with pytest.raises(Exception, match="train_on_eot"): + load_config_from_string(yaml) + + def test_oute_emotion_allowlist(self): + from soup_cli.utils.tts import validate_emotion_tag + + assert validate_emotion_tag("happy", family="oute") == "happy" + with pytest.raises(ValueError, match="oute allowlist"): + validate_emotion_tag("demonic", family="oute") + + def test_distill_divergence_literal_excludes_kl(self): + # The Literal-stored value is always the canonical form; "kl" is + # accepted at parse time (alias) but the field never holds "kl". + cfg = load_config_from_string( + "base: x\ntask: distill\ndata: {train: ./d.jsonl}\n" + "training: {teacher_model: t/m, distill_divergence: kl}\n" + ) + assert cfg.training.distill_divergence == "forward_kl" + + def test_divergences_derived_from_aliases(self): + from soup_cli.utils.distill import _DIVERGENCE_ALIASES, DIVERGENCES + + # Drift guard — adding a new alias updates both surfaces. + assert DIVERGENCES == set(_DIVERGENCE_ALIASES.keys()) + + def test_validate_tts_compat_bool_rejected(self): + from soup_cli.utils.tts import validate_tts_compat + + with pytest.raises(TypeError): + validate_tts_compat(task=True, modality="audio_out", backend="transformers") + + def test_validate_moe_quant_bool_moe_lora_rejected(self): + from soup_cli.utils.moe_quant import validate_moe_expert_quant_compat + + with pytest.raises(TypeError): + validate_moe_expert_quant_compat(backend="transformers", moe_lora=1) # type: ignore[arg-type] + + +def test_v0520_finite_helper(): + """Sanity guard: distill temperature must use math.isfinite (not just le).""" + from soup_cli.utils.distill import validate_distill_temperature + + # math.isfinite is the canonical rejector; Pydantic le=100 also rejects inf + # but only NaN slips through Field bounds incidentally. + assert math.isfinite(2.0) + with pytest.raises(ValueError, match="finite"): + validate_distill_temperature(float("nan"))