From 07e7214ed33e0561ea0a7b556dd34cc3bfe7a821 Mon Sep 17 00:00:00 2001 From: Alpamys Date: Tue, 12 May 2026 14:31:26 +0500 Subject: [PATCH] =?UTF-8?q?feat(v0.53.0):=20Quant=20Menu=20II=20=E2=80=94?= =?UTF-8?q?=20UD=20GGUFs=20+=20KV=20cache=20+=20NVFP4=20+=20LF=20parity=20?= =?UTF-8?q?+=20save=20formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema-only release. Live wiring deferred to v0.53.1 (mirrors v0.50.0 / v0.51.0 / v0.52.0 stub-then-live pattern). - Part A — Unsloth Dynamic 2.0 GGUF ladder (14 entries: UD-Q8_K_XL ... UD-IQ1_M) + validate_calibration_data_path shape validator. - Part B — IQ (12) + Apple/ARM (10) GGUF flavours in utils/gguf_quant.py; O(1) _LOWER_INDEX MappingProxyType for case-insensitive lookup. - Part C — training.kv_cache_type: q8_0 | bf16 | f16 | fp8 (fp8 Hopper-only; MLX rejected). requires_hopper reads from spec metadata (single source). - Part D — fp8_attention (requires quantization_aware='fp8') + nvfp4 (Blackwell) + native unsloth_bnb_4bit bool flags with cross-validators. - Part E — bnb_4bit_use_double_quant + llm_int8 (explicit 8bit assertion, distinct from v0.41.0 load_in_8bit aliasing) + quantize_ref_model (extends ref-task set with grpo/kto/ppo) + quantize_reward_model. - Part F — soup merge --save-format {fp16, 4bit, 4bit_forced} + soup export --format torchao with closed PTQ scheme allowlist (Int4WeightOnly, Int8DynActInt4, Float8DynActFloat8, NVFP4 — case-sensitive PyTorch names). Test count: 7453 → 7610 (+157 net new across 154 tests in test_v0530.py). ruff check soup_cli/ tests/ — clean. 5 review agents ran (python / code / security / tdd / verification); every CRITICAL / HIGH / MEDIUM / LOW finding fixed or documented: - O(N) gguf walk → O(1) _LOWER_INDEX MappingProxyType - ref_tasks extended with grpo + kto + ppo (silent-no-op footgun) - _validate_v053_bool_fields no longer coerces None → False - requires_hopper delegates to _KV_CACHE_METADATA spec - fp8_attention validator order: quantization_aware before MLX - validate_calibration_data_path + validate_quant_config_path docstrings name the exact controls v0.53.1 CLI dispatch MUST add (TOCTOU contract) - validate_torchao_scheme case-sensitivity documented at validator - tautological `result == result` test replaced with allowlist invariant - bool guards added on backend/modality/quantization across all Part D validators - exact 4096/4097 boundary tests for path shape validators Docs updated: CLAUDE.md (test counts + utils list + changelog + test-table), README.md (What's New replaced + 5 new dedicated sections), SECURITY.md (support window + v0.53.0 hardening entry), CONTRIBUTING.md (test count + utils list + test-table row), .claude/plan.md (heading + boxes + banner — gitignored, local only). Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 5 +- README.md | 47 +- SECURITY.md | 11 +- pyproject.toml | 2 +- soup_cli/__init__.py | 2 +- soup_cli/config/schema.py | 254 +++++++ soup_cli/utils/advanced_precision.py | 143 ++++ soup_cli/utils/gguf_quant.py | 292 ++++++++ soup_cli/utils/kv_cache.py | 121 +++ soup_cli/utils/save_formats.py | 208 ++++++ tests/test_v0530.py | 1018 ++++++++++++++++++++++++++ 11 files changed, 2085 insertions(+), 18 deletions(-) create mode 100644 soup_cli/utils/advanced_precision.py create mode 100644 soup_cli/utils/gguf_quant.py create mode 100644 soup_cli/utils/kv_cache.py create mode 100644 soup_cli/utils/save_formats.py create mode 100644 tests/test_v0530.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aff158a..5ea0537 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,11 +107,11 @@ soup_cli/ cans/ - Shareable .can artifact format + run/publish orchestrator (v0.26.0 + v0.33.0) data/traces/ - Trace-to-Preference harvester (v0.26.0) data/collators.py - CrossDocCollator for sample packing (v0.33.0) - utils/ - GPU, errors, MoE, GaLore, QAT, Unsloth, vLLM, SGLang, Liger, FlashAttn, FSDP, Ring Attention, long-context, quality, curriculum, freeze, dataset-registry, mlx, peft_builder, paths, topology, launcher, mii, pipeline, cut_ce, fp8, gradient_ckpt, kernel_picker, cross_doc_attn, activation_offload, hf, spec_pairing, structured_output, metrics, tracing, auto_quant, lr_finder, grad_accum, mixed_precision, warmup, spike_recovery, convergence, v028_features, multipack_sampler, multipack, neat_packing, jinja_analyzer, quant_menu, relora, peft_patches, peft_wiring, dpo_variants, optimizer_zoo, lr_groups, loftq_init, block_expansion + utils/ - GPU, errors, MoE, GaLore, QAT, Unsloth, vLLM, SGLang, Liger, FlashAttn, FSDP, Ring Attention, long-context, quality, curriculum, freeze, dataset-registry, mlx, peft_builder, paths, topology, launcher, mii, pipeline, cut_ce, fp8, gradient_ckpt, kernel_picker, cross_doc_attn, activation_offload, hf, spec_pairing, structured_output, metrics, tracing, auto_quant, lr_finder, grad_accum, mixed_precision, warmup, spike_recovery, convergence, v028_features, multipack_sampler, multipack, neat_packing, jinja_analyzer, quant_menu, relora, peft_patches, peft_wiring, dpo_variants, optimizer_zoo, lr_groups, loftq_init, block_expansion, tts, classifier, distill, bitnet, ebft_gdpo, moe_quant, reasoning_effort, gguf_quant, kv_cache, advanced_precision, save_formats 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 (179 files, 7456 tests) +tests/ - Test suite (180 files, 7610 tests) examples/ - Real-world config examples and datasets ``` @@ -263,6 +263,7 @@ pytest tests/ --cov=soup_cli --cov-report=html | 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_v0530.py | v0.53.0 Quant Menu II — UD GGUFs + KV cache + NVFP4 + LF parity + save formats: Parts A+B GGUF (UD ladder 14 entries + IQ 12 + Apple/ARM 10 frozensets + non-overlap invariant + `validate_*` case-insensitive + rejection matrix + `is_advanced_gguf_format` union + `_LOWER_INDEX` O(1) lookup + MappingProxyType immutability + `validate_calibration_data_path` shape rejection + 4096-boundary + `export_advanced_gguf` v0.53.1 deferred stub); Part C KV cache (`KV_CACHE_TYPES` frozenset + `validate_kv_cache_type` case + bool/null/oversize/non-string rejection + `requires_hopper` delegates to spec + `get_kv_cache_spec` frozen + schema fp8-on-mlx rejected with specific message + q8_0-on-mlx allowed); Part D advanced precision (`fp8_attention` requires `quantization_aware='fp8'` BEFORE mlx-gate ordering + bool guards on every string param + schema rejects-without-fp8-qat; `nvfp4` mlx + vision rejection + bool guards; `unsloth_bnb_4bit` backend='unsloth' + quantization='4bit' rejection matrix; `apply_*` deferred); Part E LF parity (`bnb_4bit_use_double_quant` rejects none/8bit/gptq parametrize; `llm_int8` rejects default-none + 4bit; `quantize_ref_model` happy on dpo/grpo/kto + rejects sft/pretrain; `quantize_reward_model` happy on ppo/reward_model + rejects dpo; explicit `TypeError("v0.53.0 flag must be bool")` from `_validate_v053_bool_fields`; explicit-null surfaces as `valid boolean` ValidationError); Part F save formats (`MERGE_SAVE_FORMATS` lowercase normalisation + rejection matrix; `TORCHAO_PTQ_SCHEMES` CASE-SENSITIVE — `int4weightonly` rejected; `validate_quant_config_path` 4096-boundary; `MergeSaveSpec` + `TorchAOPTQSpec` frozen + MappingProxyType immutability; `merge_4bit` + `export_torchao` deferred); Cross-cutting (full 5-field YAML round-trip + cardinality invariant + tautological-assert replaced with allowlist + idempotent re-validate + `get_gguf_spec` unknown raises + bool guards on backend/modality/quantization across every Part D validator). Test count: 154 (v0.53.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 1404c21..cc2860a 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,15 @@ soup train Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases). -**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. +**v0.53.0 — Quant Menu II (UD GGUFs + KV cache + NVFP4 + LF parity + save formats)**: Unsloth Dynamic 2.0 GGUF ladder (UD-Q8_K_XL … UD-IQ1_M), IQ + Apple/ARM GGUF flavours, `kv_cache_type` (q8_0 / bf16 / f16 / fp8), FP8 attention, NVFP4 (Blackwell), explicit `unsloth_bnb_4bit`, BNB double-quant, ref/reward model quantization, merge-4bit save, TorchAO PTQ export schema — schema-only release; live llama.cpp imatrix + serve / merge / export wiring lands in v0.53.1. -- **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). +- **Unsloth Dynamic 2.0 GGUF ladder.** 14-entry closed allowlist (UD-Q{8..2}_K_XL + UD-IQ{4_XS, 3_M, 3_XXS, 2_M, 2_XS, 2_XXS, 1_M, 1_S}) with `validate_ud_gguf_format` case-insensitive canonical normalisation. `--calibration-data ` flag shape-validates now; cwd-containment + TOCTOU symlink rejection land at CLI dispatch in v0.53.1. +- **IQ + Apple/ARM GGUF.** 12-entry IQ family (IQ1/2/3/4 — including IQ4_NL non-linear) + 10-entry Apple/ARM-friendly set (Q4_0_4_4 / Q4_NL / Q5_K_M / etc.) wrapped in `MappingProxyType` metadata. +- **KV cache types.** New `training.kv_cache_type: q8_0 | bf16 | f16 | fp8`. FP8 is Hopper-only — cross-validator rejects `fp8` on the MLX backend; the SM-capability check fires at serve construction. +- **FP8 attention + NVFP4 + native `unsloth_bnb_4bit`.** Three new bool flags. `fp8_attention=true` requires `quantization_aware='fp8'` and a non-MLX backend; `nvfp4=true` is gated to CUDA + text modality (Blackwell SM ≥ 12 check is runtime-only); `unsloth_bnb_4bit=true` requires `backend='unsloth'` + `quantization='4bit'`. +- **LF / Axolotl parity.** `bnb_4bit_use_double_quant` (requires `quantization='4bit'`), `llm_int8` (asserts `quantization='8bit'` — distinct from v0.41.0 `load_in_8bit` aliasing), `quantize_ref_model` (extends v0.40.5 Quant Menu to the ref model on DPO/IPO/SimPO/ORPO/BCO/KTO/GRPO/PPO/preference), `quantize_reward_model` (PPO + reward_model tasks). +- **Advanced save formats.** `soup merge --save-format 4bit | 4bit_forced` (single BNB-4bit merged checkpoint without dequant/merge/requant cycle) and `soup export --format torchao --quant-config ` (closed allowlist Int4WeightOnly / Int8DynActInt4 / Float8DynActFloat8 / NVFP4) — schema lands now; live writers land in v0.53.1. +- **+157 net new tests** (7453 → 7610) across 154 tests in `test_v0530.py`. Five review agents (python / code / security / tdd / verification) ran in parallel; every CRITICAL / HIGH / MEDIUM / LOW finding fixed or documented: O(1) `_LOWER_INDEX` for GGUF lookup, ref-task set extended with GRPO + KTO, `_validate_v053_bool_fields` no longer silently coerces `None`, `requires_hopper` reads from spec metadata, `fp8_attention` validator order swapped so the `quantization_aware='fp8'` error fires first, `validate_calibration_data_path` / `validate_quant_config_path` docstrings name the exact controls v0.53.1 CLI dispatch must add. ## Why Soup? @@ -3612,6 +3611,36 @@ Both reject silently-no-op combinations: setting either flag without `moe_lora=t `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. +## Unsloth Dynamic 2.0 GGUF Ladder (v0.53.0) + +`soup export --format gguf-ud --calibration-data ` is the planned dispatch surface for the 14-entry UD ladder (`UD-Q8_K_XL` … `UD-IQ1_M`). v0.53.0 ships the closed-allowlist validators, `MappingProxyType`-wrapped metadata, and a calibration-data path shape check; live llama.cpp `imatrix` invocation lands in v0.53.1. The IQ + Apple/ARM-friendly GGUF flavours (`IQ4_NL`, `Q4_0_4_4`, `Q5_K_M`, etc.) ship as separate frozensets so future export-CLI dispatch can pick by family. + +## KV Cache Types (v0.53.0) + +`training.kv_cache_type: q8_0 | bf16 | f16 | fp8` controls the inference-time KV cache element type. `fp8` is Hopper-only; the MLX backend is rejected at config load. The other three types pass through every backend in v0.53.0; v0.53.1 may narrow MLX further once the runtime serve path lands. The Hopper SM-capability check (compute capability ≥ 9.0) is intentionally runtime-only — `pip install -U vllm` users on a Hopper box won't trip it unless they ship a Hopper-incompatible GPU into the runtime. + +## FP8 Attention + NVFP4 + Native `unsloth_bnb_4bit` (v0.53.0) + +Three new TrainingConfig bools extend the v0.28.0 FP8 menu: + +- `fp8_attention: true` — requires `quantization_aware: fp8` AND a non-MLX backend. Targets axolotl parity for FP8 attention on Hopper+ GPUs. +- `nvfp4: true` — Blackwell-only FP4 training. Gated to non-MLX + `modality: text`; the SM ≥ 12.0 runtime check fires at trainer construction. +- `unsloth_bnb_4bit: true` — promotes "Unsloth Dynamic 4-bit" from an implicit `backend=unsloth + quantization=4bit` combo to a named flag. Mutual rejection of inconsistent combos at config load. + +Cross-validator ordering picks the most actionable error: `quantization_aware='fp8'` prerequisite fires before the MLX rejection on `fp8_attention`, so a YAML missing both surfaces the deeper issue first. + +## LF / Axolotl Quant Parity (v0.53.0) + +- `bnb_4bit_use_double_quant: true` — requires `quantization: 4bit`. Activates BNB's double-quantization. Combinations with the Quant Menu formats (gptq / awq / hqq:Nbit / aqlm / eetq / mxfp4 / fp8) are rejected at config load. +- `llm_int8: true` — an explicit 8-bit assertion. Unlike v0.41.0 `load_in_8bit` (which **rewrites** `quantization` to `8bit`), `llm_int8` enforces that the user has ALSO set `quantization: 8bit`. Mismatch raises with an actionable message. +- `quantize_ref_model: true` / `quantize_reward_model: true` — extend the v0.40.5 Quant Menu wiring to the reference / reward models inside preference and RLHF training. `quantize_ref_model` accepts any task with a reference policy (`dpo / ipo / simpo / orpo / bco / kto / preference / grpo / ppo`); `quantize_reward_model` accepts `ppo / reward_model`. + +## Advanced Save Formats (v0.53.0) + +`soup merge --save-format 4bit` and `--save-format 4bit_forced` will write a single BNB-4bit-quantized merged checkpoint without the wasteful dequant → merge → requant cycle (unsloth `merged_4bit` recipe). v0.53.0 ships the closed allowlist + spec metadata; the live writer lands in v0.53.1. + +`soup export --format torchao --quant-config ` is the planned PTQ export surface for `torchao.quantize_` + `save_pretrained`. Four schemes are allowlisted: `Int4WeightOnly`, `Int8DynActInt4`, `Float8DynActFloat8`, `NVFP4`. CASE-SENSITIVE — these are PyTorch class names and `torchao.quantize_` looks them up by exact name. Diverges from `--save-format` (lowercase-normalised) on purpose; documented at both validators. + ## Changelog See [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases) for version history. diff --git a/SECURITY.md b/SECURITY.md index 1a19ddb..b86bc49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,14 +9,14 @@ We provide security updates for the following versions: - **Versions older than 3 minor versions:** No support Example: -- v0.52.0 -- Full support (latest) +- v0.53.0 -- Full support (latest) +- v0.52.0 -- Full support - v0.51.0 -- Full support - v0.50.0 -- Full support - v0.49.0 -- Full support -- v0.48.0 -- Full support -- v0.47.0 -- Bug-fix support only -- v0.46.0-v0.46.x -- Bug-fix support only -- v0.45.x and below -- No support +- v0.48.0 -- Bug-fix support only +- v0.47.0-v0.47.x -- Bug-fix support only +- v0.46.x and below -- No support ## Reporting a Vulnerability @@ -147,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.53.0 — Quant Menu II (UD GGUFs + KV cache + NVFP4 + LF parity + save formats)**: 6 schema-only Parts; live wiring deferred to v0.53.1. Every new validator follows the project's established hardening policy: closed allowlists (`UD_GGUF_FORMATS`, `IQ_GGUF_FORMATS`, `APPLE_ARM_GGUF_FORMATS`, `KV_CACHE_TYPES`, `MERGE_SAVE_FORMATS`, `TORCHAO_PTQ_SCHEMES`) as `frozenset` so registries cannot be mutated; `_GGUF_METADATA` / `_KV_CACHE_METADATA` / `_MERGE_METADATA` / `_TORCHAO_METADATA` wrapped in `MappingProxyType`; `_LOWER_INDEX` for GGUF lookup is also `MappingProxyType`-wrapped (replaces O(N) walk with O(1) lookup — code-review MEDIUM fix). All string validators reject non-string / bool / empty / null-byte / oversize with case-insensitive normalisation (matches v0.41.0 `validate_optimizer_name` / v0.51.0 `validate_hub_name` policy); `validate_torchao_scheme` is INTENTIONALLY case-sensitive (PyTorch class names — `torchao.quantize_` looks them up by exact name) with the asymmetry documented at both validators (security-review LOW fix). `validate_calibration_data_path` + `validate_quant_config_path` are shape-only at this release; their docstrings name the exact controls a v0.53.1 CLI dispatch contributor MUST add (`os.path.realpath` + `os.path.commonpath` cwd containment, `os.lstat` + `stat.S_ISLNK` symlink rejection before `open()`, existence check, `yaml.safe_load`-only for quant configs) — closes the security-review MEDIUM "documentation gap at trust boundary" finding. SoupConfig cross-validators: `_validate_fp8_attention_compat` (requires `quantization_aware='fp8'` BEFORE the MLX gate so the more actionable error fires first — code-review MEDIUM fix); `_validate_nvfp4_compat` (non-MLX + `modality='text'`; Blackwell SM ≥ 12.0 runtime check fires at trainer construction); `_validate_unsloth_bnb_4bit_compat` (requires `backend='unsloth'` + `quantization='4bit'`); `_validate_bnb_4bit_double_quant` (requires `quantization='4bit'` — rejects `none`/`8bit`/Quant-Menu); `_validate_llm_int8_alias` (asserts `quantization='8bit'`, deliberately disjoint from v0.41.0 `load_in_8bit` aliasing); `_validate_quantize_ref_reward` (extended ref-task allowlist `{dpo, ipo, simpo, orpo, bco, kto, preference, grpo, ppo}` per code-review HIGH fix — first-cut omitted grpo + kto + ppo which all have reference policies); `_validate_kv_cache_type_supported` (only `fp8` gated to non-MLX in v0.53.0; q8_0/bf16/f16 pass-through documented at validator site so v0.53.1 contributor sees the gate immediately). `requires_hopper` reads from `_KV_CACHE_METADATA` spec — single source of truth so adding a Hopper-only type means flipping the spec field only (code-review MEDIUM fix). All 7 new bool fields share `_validate_v053_bool_fields` `field_validator(mode='before')` that rejects bool-as-int with explicit `TypeError("v0.53.0 flag must be bool")` and passes `None` through to Pydantic's `default=False` rather than silently coercing it (python-review MEDIUM fix — `fp8_attention: null` in YAML now surfaces as a "valid boolean" ValidationError instead of masquerading as `False`). Known limitations: (1) Every live wiring is deferred to v0.53.1 — `export_advanced_gguf`, `apply_kv_cache_type`, `apply_fp8_attention`, `apply_nvfp4`, `merge_4bit`, `export_torchao` all raise `NotImplementedError` with explicit `v0.53.1` markers. (2) `validate_calibration_data_path` + `validate_quant_config_path` are shape-only this release; CLI dispatch in v0.53.1 MUST add cwd-containment + TOCTOU symlink rejection. (3) `kv_cache_type` MLX permissive policy: only `fp8` is rejected, the other three pass-through; v0.53.1 may narrow further. (4) Hopper SM-capability check is runtime-only — schema accepts `kv_cache_type='fp8'` + `fp8_attention=true` without GPU probe. (5) NVFP4 + Blackwell (SM ≥ 12.0) check is runtime-only. (6) `bnb_4bit_use_double_quant` only gated against `quantization`, not against `quantization_aware` — the latter combination is already rejected by v0.28.0 Quant-Menu + QAT cross-validator. (7) `llm_int8` is an assertion not an aliaser — diverges from v0.41.0 `load_in_8bit` design on purpose. (v0.53.0) - **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 07f1062..599be67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "soup-cli" -version = "0.52.0" +version = "0.53.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 c70d9ad..c6eaebe 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.52.0" +__version__ = "0.53.0" diff --git a/soup_cli/config/schema.py b/soup_cli/config/schema.py index d3d63ac..5e57a45 100644 --- a/soup_cli/config/schema.py +++ b/soup_cli/config/schema.py @@ -1184,6 +1184,106 @@ class TrainingConfig(BaseModel): "convention. (v0.52.0)" ), ) + # ---- v0.53.0 Quant Menu II — UD GGUFs + KV cache + NVFP4 --------------- + # Part C — KV cache types (serve-side hint, captured here for round-trip). + kv_cache_type: Optional[Literal["q8_0", "bf16", "f16", "fp8"]] = Field( + default=None, + description=( + "KV-cache element type for inference (q8_0 / bf16 / f16 / fp8). " + "Schema-only in v0.53.0; live wiring deferred to v0.53.1." + ), + ) + # Part D — Train-time advanced precision. + fp8_attention: bool = Field( + default=False, + description=( + "Extend the v0.28.0 FP8 menu to FP8 attention " + "(axolotl-parity flag). Requires quantization_aware='fp8'. " + "Schema-only in v0.53.0; live wiring deferred to v0.53.1." + ), + ) + nvfp4: bool = Field( + default=False, + description=( + "Blackwell-only NVFP4 training (unsloth + axolotl). " + "Schema-only in v0.53.0; live wiring deferred to v0.53.1." + ), + ) + unsloth_bnb_4bit: bool = Field( + default=False, + description=( + "Promote Unsloth Dynamic 4-bit from 'inferable' to a native flag. " + "Requires backend='unsloth' and quantization='4bit'. (v0.53.0)" + ), + ) + # Part E — LF / Axolotl parity. + bnb_4bit_use_double_quant: bool = Field( + default=False, + description=( + "Apply BNB 4-bit double-quantization (LF / Axolotl parity). " + "Only meaningful when quantization='4bit'. (v0.53.0)" + ), + ) + llm_int8: bool = Field( + default=False, + description=( + "Explicit 8-bit LLM.int8 alias for quantization='8bit'. " + "When True, requires quantization='8bit'. (v0.53.0)" + ), + ) + quantize_ref_model: bool = Field( + default=False, + description=( + "Apply the same Quant Menu config to the reference model " + "(DPO/IPO/SimPO/ORPO/BCO ref model) — extends v0.40.5. (v0.53.0)" + ), + ) + quantize_reward_model: bool = Field( + default=False, + description=( + "Apply the same Quant Menu config to the reward model " + "(PPO/reward_model task) — extends v0.40.5. (v0.53.0)" + ), + ) + + @field_validator( + "fp8_attention", + "nvfp4", + "unsloth_bnb_4bit", + "bnb_4bit_use_double_quant", + "llm_int8", + "quantize_ref_model", + "quantize_reward_model", + mode="before", + ) + @classmethod + def _validate_v053_bool_fields(cls, v): + """v0.53.0 — explicit bool guard so YAML ``yes`` / ``1`` integers + cannot silently coerce. Matches project bool-before-int policy. + + ``None`` falls through to Pydantic so the field's ``default=False`` + applies (review-fix — avoids silent ``None → False`` coercion that + would mask YAML typos like ``fp8_attention: ~``). + """ + if v is None: + return v + if isinstance(v, bool): + return v + raise TypeError( + f"v0.53.0 flag must be bool, got {type(v).__name__}" + ) + + @field_validator("kv_cache_type", mode="before") + @classmethod + def _validate_kv_cache_type(cls, v): + """v0.53.0 Part C — bool / null-byte / oversize / case-insensitive + normalisation via the shared helper. + """ + if v is None: + return None + from soup_cli.utils.kv_cache import validate_kv_cache_type + + return validate_kv_cache_type(v) @field_validator("teacher_model") @classmethod @@ -2761,6 +2861,160 @@ class SoupConfig(BaseModel): raise ValueError(str(exc)) from exc return self + # ---- v0.53.0 Quant Menu II cross-validators ---------------------------- + + @model_validator(mode="after") + def _validate_fp8_attention_compat(self) -> "SoupConfig": + """v0.53.0 Part D — ``fp8_attention=True`` requires + ``quantization_aware='fp8'`` and a non-mlx backend. Silent-no-op + footgun rejection (mirrors v0.32.0 spike-recovery policy). + """ + tcfg = self.training + if not tcfg.fp8_attention: + return self + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + try: + validate_fp8_attention_compat( + fp8_attention=tcfg.fp8_attention, + quantization_aware=tcfg.quantization_aware, + backend=self.backend, + ) + except ValueError as exc: + raise ValueError(str(exc)) from exc + return self + + @model_validator(mode="after") + def _validate_nvfp4_compat(self) -> "SoupConfig": + """v0.53.0 Part D — ``nvfp4=True`` requires non-mlx + text-modality. + Blackwell SM-capability check is runtime-only (live wiring v0.53.1). + """ + tcfg = self.training + if not tcfg.nvfp4: + return self + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + try: + validate_nvfp4_compat( + nvfp4=tcfg.nvfp4, backend=self.backend, modality=self.modality, + ) + except ValueError as exc: + raise ValueError(str(exc)) from exc + return self + + @model_validator(mode="after") + def _validate_unsloth_bnb_4bit_compat(self) -> "SoupConfig": + """v0.53.0 Part D — ``unsloth_bnb_4bit=True`` requires + ``backend='unsloth'`` and ``quantization='4bit'``. + """ + tcfg = self.training + if not tcfg.unsloth_bnb_4bit: + return self + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + try: + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=tcfg.unsloth_bnb_4bit, + backend=self.backend, + quantization=tcfg.quantization, + ) + except ValueError as exc: + raise ValueError(str(exc)) from exc + return self + + @model_validator(mode="after") + def _validate_bnb_4bit_double_quant(self) -> "SoupConfig": + """v0.53.0 Part E — ``bnb_4bit_use_double_quant=True`` requires + ``quantization='4bit'`` (silent-no-op footgun otherwise). + """ + tcfg = self.training + if not tcfg.bnb_4bit_use_double_quant: + return self + if tcfg.quantization != "4bit": + raise ValueError( + "training.bnb_4bit_use_double_quant=true requires " + f"training.quantization='4bit'; got " + f"quantization={tcfg.quantization!r}" + ) + return self + + @model_validator(mode="after") + def _validate_llm_int8_alias(self) -> "SoupConfig": + """v0.53.0 Part E — ``llm_int8=True`` requires ``quantization='8bit'``. + + Unlike v0.41.0 ``load_in_8bit`` (which rewrites quantization), the + ``llm_int8`` flag is a pure assertion: the user explicitly says + "this is an LLM.int8 run" and we enforce the matching quantization + rather than silently rewriting it. + """ + tcfg = self.training + if not tcfg.llm_int8: + return self + if tcfg.quantization != "8bit": + raise ValueError( + "training.llm_int8=true requires training.quantization='8bit'; " + f"got quantization={tcfg.quantization!r}" + ) + return self + + @model_validator(mode="after") + def _validate_quantize_ref_reward(self) -> "SoupConfig": + """v0.53.0 Part E — ``quantize_ref_model`` requires a ref-model task + and ``quantize_reward_model`` requires a reward-model task. + Silent-no-op footgun rejection. + + Ref-model tasks (review fix): all preference-family trainers PLUS + ``grpo`` (KL to ref policy) and ``kto`` (unpaired preference, also + keeps a frozen ref). ``ppo`` also has a ref but uses a separately + named ``policy_ref`` checkpoint; covered by the reward path too. + """ + tcfg = self.training + ref_tasks = { + "dpo", "ipo", "simpo", "orpo", "bco", "kto", + "preference", "grpo", "ppo", + } + reward_tasks = {"ppo", "reward_model"} + if tcfg.quantize_ref_model and self.task not in ref_tasks: + raise ValueError( + "training.quantize_ref_model=true requires a task with a " + "reference model " + f"(one of {sorted(ref_tasks)}); got task={self.task!r}" + ) + if tcfg.quantize_reward_model and self.task not in reward_tasks: + raise ValueError( + "training.quantize_reward_model=true requires task in " + f"{sorted(reward_tasks)}; got task={self.task!r}" + ) + return self + + @model_validator(mode="after") + def _validate_kv_cache_type_supported(self) -> "SoupConfig": + """v0.53.0 Part C — ``kv_cache_type`` schema gate. + + Currently only ``fp8`` is gated (Hopper-only — MLX rejected). The + three remaining types (``q8_0`` / ``bf16`` / ``f16``) pass through + for every backend; v0.53.1 live wiring MAY need to narrow this + further (e.g. MLX serve may not support ``q8_0``). The schema-only + permissive policy is deliberate this release — kept here so the + v0.53.1 contributor sees the gate site immediately. + + Hopper SM-capability check (compute_cap >= 9.0) is runtime-only. + """ + kv = self.training.kv_cache_type + if kv is None: + return self + if self.backend == "mlx" and kv == "fp8": + raise ValueError( + "training.kv_cache_type='fp8' is not supported on the mlx " + "backend (Hopper-only). Use kv_cache_type in {q8_0,bf16,f16} " + "or switch backend." + ) + return self + @model_validator(mode="after") def _validate_relora_supported_tasks(self) -> "SoupConfig": """v0.40.6 (#67) — ReLoRA callback wired in every transformer-backend diff --git a/soup_cli/utils/advanced_precision.py b/soup_cli/utils/advanced_precision.py new file mode 100644 index 0000000..abc1f38 --- /dev/null +++ b/soup_cli/utils/advanced_precision.py @@ -0,0 +1,143 @@ +"""v0.53.0 Part D — Train-time advanced precision schema helpers. + +Three new TrainingConfig surfaces ship this release (schema-only): + +* ``fp8_attention: bool`` — extend the v0.28.0 FP8 menu to apply FP8 to + attention (axolotl-parity flag). Requires ``quantization_aware='fp8'``. +* ``nvfp4: bool`` — Blackwell-only NVFP4 training (unsloth + axolotl). Gated + to non-mlx text-modality training. +* ``unsloth_bnb_4bit: bool`` — promote Unsloth Dynamic 4-bit to a native + TrainingConfig flag (previously inferable only from ``backend='unsloth'`` + + ``quantization='4bit'``). When True, requires ``backend='unsloth'`` and + ``quantization='4bit'``. + +Live wiring lands in v0.53.1 (mirrors v0.50.0 / v0.52.0 stub-then-live). +""" + +from __future__ import annotations + + +def validate_fp8_attention_compat( + *, + fp8_attention: bool, + quantization_aware: object, + backend: str, +) -> None: + """Schema-time gate for ``fp8_attention=True``. + + Rejects: + - non-bool ``fp8_attention`` (defence-in-depth). + - ``fp8_attention=True`` without ``quantization_aware='fp8'`` (silent + no-op footgun — mirrors v0.32.0 ``loss_spike_recovery`` policy). + - non-string / empty ``backend``. + - ``backend == 'mlx'`` (MLX path has no FP8 attention kernel). + """ + if not isinstance(fp8_attention, bool): + raise TypeError( + f"fp8_attention must be bool, got {type(fp8_attention).__name__}" + ) + if not fp8_attention: + return + 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") + # Check quantization_aware prerequisite BEFORE backend gate so a YAML + # missing both gets the more actionable error (matches v0.52.0 + # validate_bitnet_compat ordering). + if quantization_aware != "fp8": + raise ValueError( + "fp8_attention=true requires training.quantization_aware='fp8' " + f"(got quantization_aware={quantization_aware!r})" + ) + if backend == "mlx": + raise ValueError( + "fp8_attention=true is not supported on backend=mlx" + ) + + +def validate_nvfp4_compat( + *, + nvfp4: bool, + backend: str, + modality: str, +) -> None: + """Schema-time gate for ``nvfp4=True``. + + NVFP4 is Blackwell-only and CUDA-only; the *runtime* SM-capability + check fires at trainer-construction time. This schema gate is the + cheap defence-in-depth layer. + """ + if not isinstance(nvfp4, bool): + raise TypeError(f"nvfp4 must be bool, got {type(nvfp4).__name__}") + if not nvfp4: + return + for name, value in (("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( + "nvfp4=true is not supported on backend=mlx " + "(NVFP4 is CUDA-only — requires Blackwell)" + ) + if modality != "text": + raise ValueError( + f"nvfp4=true is wired for modality='text' only; " + f"got modality={modality!r}" + ) + + +def validate_unsloth_bnb_4bit_compat( + *, + unsloth_bnb_4bit: bool, + backend: str, + quantization: str, +) -> None: + """Schema-time gate for ``unsloth_bnb_4bit=True``. + + Promotes "Unsloth Dynamic 4-bit" from "inferable from backend+quant" + to a native flag. The flag requires: + - ``backend == 'unsloth'`` (otherwise silently no-op). + - ``quantization == '4bit'`` (the BNB Dynamic 4-bit path; conflicts + with the v0.38.0 Quant Menu formats which raise loudly at runtime). + """ + if not isinstance(unsloth_bnb_4bit, bool): + raise TypeError( + f"unsloth_bnb_4bit must be bool, " + f"got {type(unsloth_bnb_4bit).__name__}" + ) + if not unsloth_bnb_4bit: + return + for name, value in (("backend", backend), ("quantization", quantization)): + 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 != "unsloth": + raise ValueError( + f"unsloth_bnb_4bit=true requires backend='unsloth'; " + f"got backend={backend!r}" + ) + if quantization != "4bit": + raise ValueError( + f"unsloth_bnb_4bit=true requires quantization='4bit'; " + f"got quantization={quantization!r}" + ) + + +def apply_fp8_attention() -> None: + """Live FP8-attention wiring — deferred to v0.53.1.""" + raise NotImplementedError( + "fp8_attention live wiring deferred to v0.53.1. Schema accepts the " + "flag but no torchao FP8 attention swap is registered yet." + ) + + +def apply_nvfp4() -> None: + """Live NVFP4 wiring — deferred to v0.53.1.""" + raise NotImplementedError( + "NVFP4 live wiring deferred to v0.53.1. Schema accepts the flag " + "but no Blackwell-FP4 quant prep is registered yet." + ) diff --git a/soup_cli/utils/gguf_quant.py b/soup_cli/utils/gguf_quant.py new file mode 100644 index 0000000..11b9e6d --- /dev/null +++ b/soup_cli/utils/gguf_quant.py @@ -0,0 +1,292 @@ +"""v0.53.0 Parts A+B — UD GGUF + IQ / Apple-ARM quant schema helpers. + +Schema-only support for Unsloth Dynamic 2.0 GGUF ladder (``UD-Q8_K_XL`` … +``UD-IQ1_M``), the IQ1/IQ2/IQ3 family, the Apple/ARM-friendly Q4_NL / +Q5.x / Q4.x variants, and the existing TQ1_0 1.58-bit GGUF flavour (from +v0.52.0 Part D, re-exposed here for ``soup export --format gguf-iq``). + +Live llama.cpp ``imatrix`` calibration + actual GGUF write are deferred +to v0.53.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 + +# --- Part A: Unsloth Dynamic 2.0 GGUF ladder --------------------------------- +UD_GGUF_FORMATS: frozenset[str] = frozenset({ + "UD-Q8_K_XL", + "UD-Q6_K_XL", + "UD-Q5_K_XL", + "UD-Q4_K_XL", + "UD-Q3_K_XL", + "UD-Q2_K_XL", + "UD-IQ4_XS", + "UD-IQ3_M", + "UD-IQ3_XXS", + "UD-IQ2_M", + "UD-IQ2_XS", + "UD-IQ2_XXS", + "UD-IQ1_M", + "UD-IQ1_S", +}) + +# --- Part B: IQ + Apple/ARM quant flavours ----------------------------------- +IQ_GGUF_FORMATS: frozenset[str] = frozenset({ + "IQ1_S", + "IQ1_M", + "IQ2_XXS", + "IQ2_XS", + "IQ2_S", + "IQ2_M", + "IQ3_XXS", + "IQ3_XS", + "IQ3_S", + "IQ3_M", + "IQ4_XS", + "IQ4_NL", +}) +APPLE_ARM_GGUF_FORMATS: frozenset[str] = frozenset({ + "Q4_0_4_4", + "Q4_0_4_8", + "Q4_0_8_8", + "Q4_NL", + "Q5_0", + "Q5_1", + "Q5_K_S", + "Q5_K_M", + "Q4_K_S", + "Q4_K_M", +}) + +# Union of all v0.53.0 schema-only GGUF flavours (excludes TQ1_0 which is owned +# by v0.52.0 Part D ``utils/bitnet.py``; ``is_advanced_gguf_format`` returns +# True for the BitNet family too via the helper below for export-CLI parity). +ALL_ADVANCED_GGUF_FORMATS: frozenset[str] = ( + UD_GGUF_FORMATS | IQ_GGUF_FORMATS | APPLE_ARM_GGUF_FORMATS +) + +_MAX_FORMAT_LEN: int = 32 + + +@dataclass(frozen=True) +class GGUFQuantSpec: + """Frozen metadata for a v0.53.0 advanced GGUF flavour.""" + + name: str + family: str # "ud" | "iq" | "apple_arm" + bits: float + description: str + live_wired: bool + + +def _spec(name: str, family: str, bits: float, description: str) -> GGUFQuantSpec: + return GGUFQuantSpec( + name=name, family=family, bits=bits, + description=description, live_wired=False, + ) + + +_GGUF_METADATA: Mapping[str, GGUFQuantSpec] = MappingProxyType({ + # Unsloth Dynamic 2.0 ladder + "UD-Q8_K_XL": _spec("UD-Q8_K_XL", "ud", 8.0, "UD Q8_K_XL (Unsloth Dynamic 2.0)"), + "UD-Q6_K_XL": _spec("UD-Q6_K_XL", "ud", 6.0, "UD Q6_K_XL"), + "UD-Q5_K_XL": _spec("UD-Q5_K_XL", "ud", 5.0, "UD Q5_K_XL"), + "UD-Q4_K_XL": _spec("UD-Q4_K_XL", "ud", 4.0, "UD Q4_K_XL"), + "UD-Q3_K_XL": _spec("UD-Q3_K_XL", "ud", 3.0, "UD Q3_K_XL"), + "UD-Q2_K_XL": _spec("UD-Q2_K_XL", "ud", 2.0, "UD Q2_K_XL"), + "UD-IQ4_XS": _spec("UD-IQ4_XS", "ud", 4.0, "UD IQ4_XS"), + "UD-IQ3_M": _spec("UD-IQ3_M", "ud", 3.0, "UD IQ3_M"), + "UD-IQ3_XXS": _spec("UD-IQ3_XXS", "ud", 3.0, "UD IQ3_XXS"), + "UD-IQ2_M": _spec("UD-IQ2_M", "ud", 2.0, "UD IQ2_M"), + "UD-IQ2_XS": _spec("UD-IQ2_XS", "ud", 2.0, "UD IQ2_XS"), + "UD-IQ2_XXS": _spec("UD-IQ2_XXS", "ud", 2.0, "UD IQ2_XXS"), + "UD-IQ1_M": _spec("UD-IQ1_M", "ud", 1.0, "UD IQ1_M (smallest UD)"), + "UD-IQ1_S": _spec("UD-IQ1_S", "ud", 1.0, "UD IQ1_S"), + # IQ family (non-UD) + "IQ1_S": _spec("IQ1_S", "iq", 1.0, "IQ1_S 1-bit"), + "IQ1_M": _spec("IQ1_M", "iq", 1.0, "IQ1_M 1-bit"), + "IQ2_XXS": _spec("IQ2_XXS", "iq", 2.0, "IQ2_XXS 2-bit"), + "IQ2_XS": _spec("IQ2_XS", "iq", 2.0, "IQ2_XS 2-bit"), + "IQ2_S": _spec("IQ2_S", "iq", 2.0, "IQ2_S 2-bit"), + "IQ2_M": _spec("IQ2_M", "iq", 2.0, "IQ2_M 2-bit"), + "IQ3_XXS": _spec("IQ3_XXS", "iq", 3.0, "IQ3_XXS 3-bit"), + "IQ3_XS": _spec("IQ3_XS", "iq", 3.0, "IQ3_XS 3-bit"), + "IQ3_S": _spec("IQ3_S", "iq", 3.0, "IQ3_S 3-bit"), + "IQ3_M": _spec("IQ3_M", "iq", 3.0, "IQ3_M 3-bit"), + "IQ4_XS": _spec("IQ4_XS", "iq", 4.0, "IQ4_XS 4-bit"), + "IQ4_NL": _spec("IQ4_NL", "iq", 4.0, "IQ4_NL 4-bit (non-linear)"), + # Apple/ARM neural-engine-friendly + "Q4_0_4_4": _spec("Q4_0_4_4", "apple_arm", 4.0, "Apple/ARM Q4_0_4_4"), + "Q4_0_4_8": _spec("Q4_0_4_8", "apple_arm", 4.0, "Apple/ARM Q4_0_4_8"), + "Q4_0_8_8": _spec("Q4_0_8_8", "apple_arm", 4.0, "Apple/ARM Q4_0_8_8"), + "Q4_NL": _spec("Q4_NL", "apple_arm", 4.0, "Apple/ARM Q4_NL"), + "Q5_0": _spec("Q5_0", "apple_arm", 5.0, "Apple/ARM Q5_0"), + "Q5_1": _spec("Q5_1", "apple_arm", 5.0, "Apple/ARM Q5_1"), + "Q5_K_S": _spec("Q5_K_S", "apple_arm", 5.0, "Apple/ARM Q5_K_S"), + "Q5_K_M": _spec("Q5_K_M", "apple_arm", 5.0, "Apple/ARM Q5_K_M"), + "Q4_K_S": _spec("Q4_K_S", "apple_arm", 4.0, "Apple/ARM Q4_K_S"), + "Q4_K_M": _spec("Q4_K_M", "apple_arm", 4.0, "Apple/ARM Q4_K_M"), +}) + + +def _basic_validate(value: object, field: str) -> str: + if isinstance(value, bool): + raise TypeError(f"{field} must not be bool, got {value!r}") + if not isinstance(value, str): + raise TypeError(f"{field} must be str, got {type(value).__name__}") + if not value: + raise ValueError(f"{field} must be non-empty") + if "\x00" in value: + raise ValueError(f"{field} must not contain null bytes") + if len(value) > _MAX_FORMAT_LEN: + raise ValueError(f"{field} too long (max {_MAX_FORMAT_LEN} chars)") + return value + + +# Lowercase index built once at module load — O(1) lookup vs O(N) walk +# (code-review MEDIUM fix). Mirrors v0.32.0 ``pick_mixed_precision`` quirk +# ordering policy where sorting / indexing is precomputed. +_LOWER_INDEX: Mapping[str, str] = MappingProxyType({ + name.lower(): name for name in ALL_ADVANCED_GGUF_FORMATS +}) + + +def _resolve_canonical(value: str) -> str | None: + """Match ``value`` against the union allowlist case-insensitively. + + Returns the canonical (original-case) entry from the allowlist or + ``None`` if no match. + """ + return _LOWER_INDEX.get(value.lower()) + + +def validate_ud_gguf_format(value: object) -> str: + """Validate ``value`` is a UD GGUF format string. Returns canonical form.""" + _basic_validate(value, "ud_gguf_format") + canonical = _resolve_canonical(value) # type: ignore[arg-type] + if canonical is None or canonical not in UD_GGUF_FORMATS: + supported = ", ".join(sorted(UD_GGUF_FORMATS)) + raise ValueError( + f"ud_gguf_format {value!r} not supported. Supported: {supported}" + ) + return canonical + + +def validate_iq_gguf_format(value: object) -> str: + """Validate ``value`` is an IQ GGUF format string. Returns canonical form.""" + _basic_validate(value, "iq_gguf_format") + canonical = _resolve_canonical(value) # type: ignore[arg-type] + if canonical is None or canonical not in IQ_GGUF_FORMATS: + supported = ", ".join(sorted(IQ_GGUF_FORMATS)) + raise ValueError( + f"iq_gguf_format {value!r} not supported. Supported: {supported}" + ) + return canonical + + +def validate_apple_arm_gguf_format(value: object) -> str: + """Validate ``value`` is an Apple/ARM GGUF format string.""" + _basic_validate(value, "apple_arm_gguf_format") + canonical = _resolve_canonical(value) # type: ignore[arg-type] + if canonical is None or canonical not in APPLE_ARM_GGUF_FORMATS: + supported = ", ".join(sorted(APPLE_ARM_GGUF_FORMATS)) + raise ValueError( + f"apple_arm_gguf_format {value!r} not supported. " + f"Supported: {supported}" + ) + return canonical + + +def is_ud_gguf_format(value: object) -> bool: + """Return True iff ``value`` is one of the UD GGUF ladder entries.""" + if isinstance(value, bool) or not isinstance(value, str): + return False + canonical = _resolve_canonical(value) + return canonical is not None and canonical in UD_GGUF_FORMATS + + +def is_iq_gguf_format(value: object) -> bool: + """Return True iff ``value`` is one of the IQ GGUF flavours.""" + if isinstance(value, bool) or not isinstance(value, str): + return False + canonical = _resolve_canonical(value) + return canonical is not None and canonical in IQ_GGUF_FORMATS + + +def is_apple_arm_gguf_format(value: object) -> bool: + """Return True iff ``value`` is one of the Apple/ARM GGUF flavours.""" + if isinstance(value, bool) or not isinstance(value, str): + return False + canonical = _resolve_canonical(value) + return canonical is not None and canonical in APPLE_ARM_GGUF_FORMATS + + +def is_advanced_gguf_format(value: object) -> bool: + """Return True iff ``value`` is any v0.53.0 advanced GGUF format.""" + return ( + is_ud_gguf_format(value) + or is_iq_gguf_format(value) + or is_apple_arm_gguf_format(value) + ) + + +def get_gguf_spec(name: str) -> GGUFQuantSpec: + """Return the frozen :class:`GGUFQuantSpec` for ``name`` (case-insensitive).""" + if isinstance(name, bool) or not isinstance(name, str): + raise TypeError(f"name must be str, got {type(name).__name__}") + canonical = _resolve_canonical(name) + if canonical is None: + supported_n = len(ALL_ADVANCED_GGUF_FORMATS) + raise ValueError( + f"GGUF format {name!r} not in v0.53.0 catalog " + f"({supported_n} known)" + ) + return _GGUF_METADATA[canonical] + + +def validate_calibration_data_path(path: object) -> str: + """Validate ``--calibration-data `` argument shape. + + Boundary contract — what's enforced HERE vs at CLI dispatch (v0.53.1): + + THIS helper enforces: + * non-empty ``str`` (bool / None / other types rejected with ``TypeError``) + * no null bytes + * length <= 4096 chars + + CLI dispatch in v0.53.1 MUST additionally apply (mirrors v0.43.0 / + v0.46.0 / v0.47.0 TOCTOU policy): + * ``os.path.realpath`` + ``os.path.commonpath`` cwd containment + * ``os.lstat`` + ``stat.S_ISLNK`` rejection BEFORE any ``open()`` + * existence check via ``os.path.isfile`` + + Do NOT skip the dispatch-time controls — this helper is shape-only. + """ + if isinstance(path, bool): + raise TypeError(f"calibration_data must not be bool, got {path!r}") + if not isinstance(path, str): + raise TypeError( + f"calibration_data must be str, got {type(path).__name__}" + ) + if not path: + raise ValueError("calibration_data must be non-empty") + if "\x00" in path: + raise ValueError("calibration_data must not contain null bytes") + if len(path) > 4096: + raise ValueError("calibration_data path too long (max 4096 chars)") + return path + + +def export_advanced_gguf() -> None: + """Live UD/IQ/Apple-ARM GGUF export — deferred to v0.53.1. + + Mirrors v0.52.0 ``export_bitnet_gguf`` stub-then-live pattern. + """ + raise NotImplementedError( + "UD / IQ / Apple-ARM GGUF export live wiring deferred to v0.53.1. " + "Schema accepts every format in ALL_ADVANCED_GGUF_FORMATS but no " + "llama.cpp imatrix invocation is registered yet." + ) diff --git a/soup_cli/utils/kv_cache.py b/soup_cli/utils/kv_cache.py new file mode 100644 index 0000000..1fbb7e8 --- /dev/null +++ b/soup_cli/utils/kv_cache.py @@ -0,0 +1,121 @@ +"""v0.53.0 Part C — KV cache types schema helpers. + +Closed allowlist of ``kv_cache_type`` strings exposed to +``soup serve --kv-cache-type `` and YAML ``training.kv_cache_type`` (the +field is reused by serve / chat runtime in v0.53.1). Mirrors the unsloth +serve recipe. + +* ``q8_0`` — 8-bit (default for the unsloth runtime) +* ``bf16`` — bfloat16 +* ``f16`` — float16 +* ``fp8`` — FP8 on Hopper+ (gated by a separate runtime check) + +Live wiring into the vLLM / SGLang / transformers serve loops is deferred +to v0.53.1. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + +KV_CACHE_TYPES: frozenset[str] = frozenset({"q8_0", "bf16", "f16", "fp8"}) + +_MAX_KV_CACHE_LEN: int = 16 + + +@dataclass(frozen=True) +class KVCacheSpec: + """Frozen metadata for a KV-cache type. Immutable by construction.""" + + name: str + bits: int + requires_hopper: bool + description: str + live_wired: bool + + +_KV_CACHE_METADATA: Mapping[str, KVCacheSpec] = MappingProxyType({ + "q8_0": KVCacheSpec( + name="q8_0", bits=8, requires_hopper=False, + description="8-bit KV cache (default for unsloth runtime)", + live_wired=False, + ), + "bf16": KVCacheSpec( + name="bf16", bits=16, requires_hopper=False, + description="bfloat16 KV cache", + live_wired=False, + ), + "f16": KVCacheSpec( + name="f16", bits=16, requires_hopper=False, + description="float16 KV cache", + live_wired=False, + ), + "fp8": KVCacheSpec( + name="fp8", bits=8, requires_hopper=True, + description="FP8 KV cache (Hopper+ only)", + live_wired=False, + ), +}) + + +def validate_kv_cache_type(value: object) -> str: + """Validate a ``kv_cache_type`` string and return the canonical form. + + Mirrors v0.52.0 ``validate_reasoning_effort`` policy: bool-first / + null-byte / oversize / case-insensitive normalisation. + """ + if isinstance(value, bool): + raise TypeError(f"kv_cache_type must not be bool, got {value!r}") + if not isinstance(value, str): + raise TypeError( + f"kv_cache_type must be str, got {type(value).__name__}" + ) + if not value: + raise ValueError("kv_cache_type must be non-empty") + if "\x00" in value: + raise ValueError("kv_cache_type must not contain null bytes") + if len(value) > _MAX_KV_CACHE_LEN: + raise ValueError( + f"kv_cache_type too long (max {_MAX_KV_CACHE_LEN} chars)" + ) + canonical = value.lower() + if canonical not in KV_CACHE_TYPES: + supported = ", ".join(sorted(KV_CACHE_TYPES)) + raise ValueError( + f"kv_cache_type {value!r} not supported. Supported: {supported}" + ) + return canonical + + +def get_kv_cache_spec(name: str) -> KVCacheSpec: + """Return the frozen :class:`KVCacheSpec` for ``name`` (canonical).""" + canonical = validate_kv_cache_type(name) + return _KV_CACHE_METADATA[canonical] + + +def requires_hopper(name: object) -> bool: + """Return True iff ``name`` needs a Hopper+ GPU. + + Single source of truth: reads ``requires_hopper`` from the + ``_KV_CACHE_METADATA`` spec. Adding a Hopper-only type means flipping + the spec field only — no separate update here (code-review MEDIUM fix). + """ + if isinstance(name, bool) or not isinstance(name, str): + return False + canonical = name.lower() + spec = _KV_CACHE_METADATA.get(canonical) + return spec is not None and spec.requires_hopper + + +def apply_kv_cache_type() -> None: + """Live KV-cache-type wiring — deferred to v0.53.1. + + Mirrors v0.50.0 ``apply_vllm_sleep_mode`` and v0.52.0 + ``apply_moe_expert_quant`` stub-then-live pattern. + """ + raise NotImplementedError( + "kv_cache_type live wiring deferred to v0.53.1. Schema accepts " + "q8_0 / bf16 / f16 / fp8 but no serve backend is routing the flag yet." + ) diff --git a/soup_cli/utils/save_formats.py b/soup_cli/utils/save_formats.py new file mode 100644 index 0000000..788734d --- /dev/null +++ b/soup_cli/utils/save_formats.py @@ -0,0 +1,208 @@ +"""v0.53.0 Part F — Advanced save / merge formats schema helpers. + +Two new save-format surfaces ship this release (schema-only): + +* ``soup merge --save-format `` where fmt ∈ {fp16, 4bit, 4bit_forced}. + ``4bit`` writes a single BNB-4bit-quantized merged checkpoint without + the dequant → merge → requant cycle (unsloth ``merged_4bit`` recipe). + ``4bit_forced`` is the unsloth ``4bit_forced`` shortcut. +* ``soup export --format torchao --quant-config `` — invokes + ``torchao.quantize_`` then ``save_pretrained`` for the + ``Int4WeightOnly`` / ``Int8DynActInt4`` / ``Float8DynActFloat8`` / + ``NVFP4`` PTQ schemes (unsloth + axolotl parity). + +Live wiring deferred to v0.53.1. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + +# --- Merge save formats ------------------------------------------------------ +MERGE_SAVE_FORMATS: frozenset[str] = frozenset({ + "fp16", "4bit", "4bit_forced", +}) + +# --- TorchAO PTQ schemes (a closed allowlist, mirrors the v0.38.0 Quant Menu +# string convention so YAML round-trips cleanly). +TORCHAO_PTQ_SCHEMES: frozenset[str] = frozenset({ + "Int4WeightOnly", + "Int8DynActInt4", + "Float8DynActFloat8", + "NVFP4", +}) + +_MAX_SAVE_FORMAT_LEN: int = 32 +_MAX_TORCHAO_SCHEME_LEN: int = 48 + + +@dataclass(frozen=True) +class MergeSaveSpec: + """Frozen metadata for a merge-save format.""" + + name: str + bits: int + description: str + live_wired: bool + + +_MERGE_METADATA: Mapping[str, MergeSaveSpec] = MappingProxyType({ + "fp16": MergeSaveSpec( + name="fp16", bits=16, + description="Standard FP16 merged checkpoint (default — pre-v0.53.0)", + live_wired=True, + ), + "4bit": MergeSaveSpec( + name="4bit", bits=4, + description="Single BNB-4bit-quantized merged checkpoint", + live_wired=False, + ), + "4bit_forced": MergeSaveSpec( + name="4bit_forced", bits=4, + description="Forced BNB-4bit merge (unsloth 4bit_forced)", + live_wired=False, + ), +}) + + +@dataclass(frozen=True) +class TorchAOPTQSpec: + """Frozen metadata for a TorchAO PTQ scheme.""" + + name: str + bits: int + description: str + live_wired: bool + + +_TORCHAO_METADATA: Mapping[str, TorchAOPTQSpec] = MappingProxyType({ + "Int4WeightOnly": TorchAOPTQSpec( + name="Int4WeightOnly", bits=4, + description="TorchAO Int4WeightOnly (weight-only int4)", + live_wired=False, + ), + "Int8DynActInt4": TorchAOPTQSpec( + name="Int8DynActInt4", bits=4, + description="TorchAO Int8DynActInt4 (dynamic int8 act + int4 weight)", + live_wired=False, + ), + "Float8DynActFloat8": TorchAOPTQSpec( + name="Float8DynActFloat8", bits=8, + description="TorchAO FP8 dynamic activations + FP8 weight", + live_wired=False, + ), + "NVFP4": TorchAOPTQSpec( + name="NVFP4", bits=4, + description="TorchAO NVFP4 (Blackwell FP4 PTQ)", + live_wired=False, + ), +}) + + +def _validate_string_field(value: object, field: str, max_len: int) -> str: + if isinstance(value, bool): + raise TypeError(f"{field} must not be bool, got {value!r}") + if not isinstance(value, str): + raise TypeError(f"{field} must be str, got {type(value).__name__}") + if not value: + raise ValueError(f"{field} must be non-empty") + if "\x00" in value: + raise ValueError(f"{field} must not contain null bytes") + if len(value) > max_len: + raise ValueError(f"{field} too long (max {max_len} chars)") + return value + + +def validate_merge_save_format(value: object) -> str: + """Validate ``--save-format`` arg. Case-insensitive normalisation. + + Mirrors v0.41.0 ``optimizer`` policy. Returns the lowercase canonical + form so ``_MERGE_METADATA`` (all-lowercase keys) lookups are O(1). + """ + validated = _validate_string_field(value, "save_format", _MAX_SAVE_FORMAT_LEN) + canonical = validated.lower() + if canonical not in MERGE_SAVE_FORMATS: + supported = ", ".join(sorted(MERGE_SAVE_FORMATS)) + raise ValueError( + f"save_format {value!r} not supported. Supported: {supported}" + ) + return canonical + + +def validate_torchao_scheme(value: object) -> str: + """Validate a TorchAO PTQ scheme name. + + INTENTIONALLY CASE-SENSITIVE — these are PyTorch class names + (``Int4WeightOnly`` / ``NVFP4`` etc.) that ``torchao.quantize_`` looks up + by exact name. Diverges from ``validate_merge_save_format`` (which + lowercase-normalises) and ``validate_kv_cache_type`` (also lowercase) + on purpose: TorchAO uses CapWords, the others are operator-facing flags. + """ + validated = _validate_string_field( + value, "torchao_scheme", _MAX_TORCHAO_SCHEME_LEN, + ) + if validated not in TORCHAO_PTQ_SCHEMES: + supported = ", ".join(sorted(TORCHAO_PTQ_SCHEMES)) + raise ValueError( + f"torchao_scheme {value!r} not supported. Supported: {supported}" + ) + return validated + + +def get_merge_save_spec(name: str) -> MergeSaveSpec: + """Return the frozen :class:`MergeSaveSpec` for ``name`` (case-insensitive).""" + canonical = validate_merge_save_format(name) + return _MERGE_METADATA[canonical] + + +def get_torchao_spec(name: str) -> TorchAOPTQSpec: + """Return the frozen :class:`TorchAOPTQSpec` for ``name`` (case-sensitive).""" + canonical = validate_torchao_scheme(name) + return _TORCHAO_METADATA[canonical] + + +def validate_quant_config_path(path: object) -> str: + """Validate ``--quant-config `` argument shape. + + Boundary contract — what's enforced HERE vs at CLI dispatch (v0.53.1): + + THIS helper enforces non-empty ``str``, no null bytes, length <= 4096. + + CLI dispatch in v0.53.1 MUST additionally apply (mirrors v0.43.0 / + v0.46.0 / v0.47.0 TOCTOU policy): + * ``os.path.realpath`` + ``os.path.commonpath`` cwd containment + * ``os.lstat`` + ``stat.S_ISLNK`` rejection before any ``open()`` + * existence + extension check (``.yaml`` / ``.yml``) + * ``yaml.safe_load`` only. + """ + if isinstance(path, bool): + raise TypeError(f"quant_config must not be bool, got {path!r}") + if not isinstance(path, str): + raise TypeError( + f"quant_config must be str, got {type(path).__name__}" + ) + if not path: + raise ValueError("quant_config must be non-empty") + if "\x00" in path: + raise ValueError("quant_config must not contain null bytes") + if len(path) > 4096: + raise ValueError("quant_config path too long (max 4096 chars)") + return path + + +def merge_4bit() -> None: + """Live 4bit-merge wiring — deferred to v0.53.1.""" + raise NotImplementedError( + "soup merge --save-format 4bit live wiring deferred to v0.53.1. " + "Schema accepts the flag but no merged-4bit writer is registered yet." + ) + + +def export_torchao() -> None: + """Live TorchAO PTQ export — deferred to v0.53.1.""" + raise NotImplementedError( + "soup export --format torchao live wiring deferred to v0.53.1. " + "Schema accepts the format but no torchao.quantize_ call is wired." + ) diff --git a/tests/test_v0530.py b/tests/test_v0530.py new file mode 100644 index 0000000..b5d11af --- /dev/null +++ b/tests/test_v0530.py @@ -0,0 +1,1018 @@ +"""v0.53.0 Quant Menu II — UD GGUFs + KV cache + NVFP4 + LF parity + save formats. + +Schema-only test suite. Live wiring deferred to v0.53.1 (mirrors v0.50.0 / +v0.51.0 / v0.52.0 single-file test layout). +""" + +from __future__ import annotations + +from types import MappingProxyType + +import pytest + +from soup_cli.config.loader import load_config_from_string + +# --------------------------------------------------------------------------- +# Part A — Unsloth Dynamic 2.0 GGUF ladder +# --------------------------------------------------------------------------- + + +class TestUDGGUF: + def test_ud_formats_frozenset(self): + from soup_cli.utils.gguf_quant import UD_GGUF_FORMATS + + assert isinstance(UD_GGUF_FORMATS, frozenset) + # 6 K-XL + 8 IQ variants = 14 total + assert len(UD_GGUF_FORMATS) == 14 + assert "UD-Q8_K_XL" in UD_GGUF_FORMATS + assert "UD-IQ1_M" in UD_GGUF_FORMATS + assert "UD-IQ1_S" in UD_GGUF_FORMATS + assert "UD-IQ2_XXS" in UD_GGUF_FORMATS + + @pytest.mark.parametrize( + "name", [ + "UD-Q8_K_XL", "UD-Q4_K_XL", "UD-IQ1_M", + "ud-q8_k_xl", "Ud-Iq1_M", + ], + ) + def test_validate_ud_canonical_case_insensitive(self, name): + from soup_cli.utils.gguf_quant import UD_GGUF_FORMATS, validate_ud_gguf_format + + result = validate_ud_gguf_format(name) + # Returns the canonical entry from the allowlist regardless of input case. + assert result in UD_GGUF_FORMATS + # Idempotent: validating the canonical form returns the same value. + assert validate_ud_gguf_format(result) == result + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (123, TypeError), + (b"UD-Q8_K_XL", TypeError), + ("", ValueError), + ("UD-Q8\x00_K_XL", ValueError), + ("U" * 64, ValueError), + ("UD-UNKNOWN", ValueError), + ("Q4_K_M", ValueError), # apple/arm — not UD + ], + ) + def test_validate_ud_rejects(self, bad, exc): + from soup_cli.utils.gguf_quant import validate_ud_gguf_format + + with pytest.raises(exc): + validate_ud_gguf_format(bad) + + def test_is_ud_gguf_format(self): + from soup_cli.utils.gguf_quant import is_ud_gguf_format + + assert is_ud_gguf_format("UD-Q4_K_XL") is True + assert is_ud_gguf_format("ud-iq1_m") is True + assert is_ud_gguf_format("IQ4_XS") is False + assert is_ud_gguf_format("Q4_K_M") is False + assert is_ud_gguf_format(True) is False + assert is_ud_gguf_format(None) is False + assert is_ud_gguf_format(123) is False + + def test_spec_frozen(self): + from soup_cli.utils.gguf_quant import get_gguf_spec + + spec = get_gguf_spec("UD-Q4_K_XL") + assert spec.family == "ud" + assert spec.live_wired is False + with pytest.raises(Exception): + spec.live_wired = True # type: ignore[misc] + + def test_calibration_data_validator(self): + from soup_cli.utils.gguf_quant import validate_calibration_data_path + + assert validate_calibration_data_path("calib.jsonl") == "calib.jsonl" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + (123, TypeError), + ("", ValueError), + ("foo\x00bar", ValueError), + ("x" * 5000, ValueError), + ], + ) + def test_calibration_data_rejects(self, bad, exc): + from soup_cli.utils.gguf_quant import validate_calibration_data_path + + with pytest.raises(exc): + validate_calibration_data_path(bad) + + def test_export_deferred(self): + from soup_cli.utils.gguf_quant import export_advanced_gguf + + with pytest.raises(NotImplementedError, match="v0.53.1"): + export_advanced_gguf() + + +# --------------------------------------------------------------------------- +# Part B — IQ + Apple/ARM GGUF +# --------------------------------------------------------------------------- + + +class TestIQAppleARMGGUF: + def test_iq_frozenset(self): + from soup_cli.utils.gguf_quant import IQ_GGUF_FORMATS + + assert isinstance(IQ_GGUF_FORMATS, frozenset) + assert "IQ1_S" in IQ_GGUF_FORMATS + assert "IQ4_NL" in IQ_GGUF_FORMATS + # Should not have UD- prefix + assert all(not n.startswith("UD-") for n in IQ_GGUF_FORMATS) + + def test_apple_arm_frozenset(self): + from soup_cli.utils.gguf_quant import APPLE_ARM_GGUF_FORMATS + + assert isinstance(APPLE_ARM_GGUF_FORMATS, frozenset) + assert "Q4_NL" in APPLE_ARM_GGUF_FORMATS + assert "Q4_0_4_4" in APPLE_ARM_GGUF_FORMATS + assert "Q5_K_M" in APPLE_ARM_GGUF_FORMATS + + @pytest.mark.parametrize( + "name", ["IQ1_S", "iq2_m", "IQ3_XXS", "IQ4_NL"], + ) + def test_validate_iq_canonical(self, name): + from soup_cli.utils.gguf_quant import validate_iq_gguf_format + + result = validate_iq_gguf_format(name) + assert result.startswith("IQ") + + def test_validate_iq_rejects_ud(self): + from soup_cli.utils.gguf_quant import validate_iq_gguf_format + + with pytest.raises(ValueError, match="not supported"): + validate_iq_gguf_format("UD-IQ1_M") + + def test_validate_apple_arm_canonical(self): + from soup_cli.utils.gguf_quant import validate_apple_arm_gguf_format + + assert validate_apple_arm_gguf_format("Q4_NL") == "Q4_NL" + assert validate_apple_arm_gguf_format("q5_k_m") == "Q5_K_M" + + def test_is_iq_and_apple_arm(self): + from soup_cli.utils.gguf_quant import ( + is_apple_arm_gguf_format, + is_iq_gguf_format, + ) + + assert is_iq_gguf_format("IQ2_M") is True + assert is_iq_gguf_format("UD-IQ2_M") is False + assert is_apple_arm_gguf_format("Q4_NL") is True + assert is_apple_arm_gguf_format("IQ1_S") is False + + def test_is_advanced_gguf_format(self): + from soup_cli.utils.gguf_quant import is_advanced_gguf_format + + assert is_advanced_gguf_format("UD-Q4_K_XL") is True + assert is_advanced_gguf_format("IQ2_M") is True + assert is_advanced_gguf_format("Q4_NL") is True + assert is_advanced_gguf_format("q4_0") is False # legacy llama.cpp + assert is_advanced_gguf_format(123) is False + + def test_all_advanced_no_overlap(self): + from soup_cli.utils.gguf_quant import ( + APPLE_ARM_GGUF_FORMATS, + IQ_GGUF_FORMATS, + UD_GGUF_FORMATS, + ) + + # UD-prefixed entries belong only to UD + assert UD_GGUF_FORMATS & IQ_GGUF_FORMATS == frozenset() + assert UD_GGUF_FORMATS & APPLE_ARM_GGUF_FORMATS == frozenset() + assert IQ_GGUF_FORMATS & APPLE_ARM_GGUF_FORMATS == frozenset() + + +# --------------------------------------------------------------------------- +# Part C — KV cache types +# --------------------------------------------------------------------------- + + +class TestKVCache: + def test_kv_cache_types_frozenset(self): + from soup_cli.utils.kv_cache import KV_CACHE_TYPES + + assert isinstance(KV_CACHE_TYPES, frozenset) + assert KV_CACHE_TYPES == {"q8_0", "bf16", "f16", "fp8"} + + @pytest.mark.parametrize( + "name", ["q8_0", "Q8_0", "bf16", "BF16", "f16", "fp8", "FP8"], + ) + def test_validate_kv_canonical(self, name): + from soup_cli.utils.kv_cache import validate_kv_cache_type + + assert validate_kv_cache_type(name) == name.lower() + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("q8\x00_0", ValueError), + ("q" * 20, ValueError), + ("int8", ValueError), + ("q4_0", ValueError), + ], + ) + def test_validate_kv_rejects(self, bad, exc): + from soup_cli.utils.kv_cache import validate_kv_cache_type + + with pytest.raises(exc): + validate_kv_cache_type(bad) + + def test_requires_hopper(self): + from soup_cli.utils.kv_cache import requires_hopper + + assert requires_hopper("fp8") is True + assert requires_hopper("FP8") is True + assert requires_hopper("q8_0") is False + assert requires_hopper(True) is False + assert requires_hopper(None) is False + + def test_spec_frozen_and_metadata(self): + from soup_cli.utils.kv_cache import get_kv_cache_spec + + spec = get_kv_cache_spec("fp8") + assert spec.requires_hopper is True + assert spec.bits == 8 + with pytest.raises(Exception): + spec.requires_hopper = False # type: ignore[misc] + + def test_apply_deferred(self): + from soup_cli.utils.kv_cache import apply_kv_cache_type + + with pytest.raises(NotImplementedError, match="v0.53.1"): + apply_kv_cache_type() + + def test_metadata_immutable(self): + from soup_cli.utils.kv_cache import _KV_CACHE_METADATA + + assert isinstance(_KV_CACHE_METADATA, MappingProxyType) + with pytest.raises(TypeError): + _KV_CACHE_METADATA["evil"] = None # type: ignore[index] + + +class TestKVCacheSchema: + def test_schema_default_none(self): + cfg = load_config_from_string( + "base: TinyLlama/TinyLlama-1.1B-Chat-v1.0\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + ) + assert cfg.training.kv_cache_type is None + + def test_schema_accept_q8(self): + cfg = load_config_from_string( + "base: TinyLlama/TinyLlama-1.1B-Chat-v1.0\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {kv_cache_type: q8_0}\n" + ) + assert cfg.training.kv_cache_type == "q8_0" + + def test_schema_case_insensitive_normalisation(self): + cfg = load_config_from_string( + "base: TinyLlama/TinyLlama-1.1B-Chat-v1.0\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {kv_cache_type: BF16}\n" + ) + assert cfg.training.kv_cache_type == "bf16" + + def test_schema_rejects_unknown(self): + with pytest.raises(ValueError, match="not supported|kv_cache_type"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {kv_cache_type: int8}\n" + ) + + def test_schema_fp8_rejected_on_mlx(self): + with pytest.raises( + ValueError, + match="kv_cache_type='fp8' is not supported on the mlx", + ): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "backend: mlx\n" + "data: {train: x.jsonl}\n" + "training: {kv_cache_type: fp8}\n" + ) + + def test_schema_q8_0_allowed_on_mlx(self): + cfg = load_config_from_string( + "base: mlx-community/foo\n" + "task: sft\n" + "backend: mlx\n" + "data: {train: x.jsonl}\n" + "training: {kv_cache_type: q8_0}\n" + ) + assert cfg.training.kv_cache_type == "q8_0" + + +# --------------------------------------------------------------------------- +# Part D — FP8 attention + NVFP4 + unsloth_bnb_4bit +# --------------------------------------------------------------------------- + + +class TestFP8Attention: + def test_compat_off_no_check(self): + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + validate_fp8_attention_compat( + fp8_attention=False, quantization_aware=False, backend="transformers", + ) + + def test_compat_happy(self): + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + validate_fp8_attention_compat( + fp8_attention=True, quantization_aware="fp8", backend="transformers", + ) + + def test_compat_requires_fp8_qat(self): + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + with pytest.raises(ValueError, match="quantization_aware='fp8'"): + validate_fp8_attention_compat( + fp8_attention=True, quantization_aware=False, + backend="transformers", + ) + + def test_compat_rejects_mlx(self): + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + with pytest.raises(ValueError, match="mlx"): + validate_fp8_attention_compat( + fp8_attention=True, quantization_aware="fp8", backend="mlx", + ) + + def test_compat_bool_guard(self): + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + with pytest.raises(TypeError, match="bool"): + validate_fp8_attention_compat( + fp8_attention=1, # type: ignore[arg-type] + quantization_aware="fp8", + backend="transformers", + ) + + def test_schema_default_false(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + ) + assert cfg.training.fp8_attention is False + + def test_schema_happy(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {fp8_attention: true, quantization_aware: fp8}\n" + ) + assert cfg.training.fp8_attention is True + + def test_schema_rejects_without_fp8_qat(self): + with pytest.raises(ValueError, match="quantization_aware='fp8'"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {fp8_attention: true}\n" + ) + + def test_apply_deferred(self): + from soup_cli.utils.advanced_precision import apply_fp8_attention + + with pytest.raises(NotImplementedError, match="v0.53.1"): + apply_fp8_attention() + + +class TestNVFP4: + def test_compat_off_no_check(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + validate_nvfp4_compat( + nvfp4=False, backend="transformers", modality="text", + ) + + def test_compat_happy(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + validate_nvfp4_compat( + nvfp4=True, backend="transformers", modality="text", + ) + + def test_compat_rejects_mlx(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + with pytest.raises(ValueError, match="mlx"): + validate_nvfp4_compat(nvfp4=True, backend="mlx", modality="text") + + def test_compat_rejects_vision(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + with pytest.raises(ValueError, match="text"): + validate_nvfp4_compat( + nvfp4=True, backend="transformers", modality="vision", + ) + + def test_compat_bool_guard(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + with pytest.raises(TypeError, match="bool"): + validate_nvfp4_compat( + nvfp4=1, # type: ignore[arg-type] + backend="transformers", + modality="text", + ) + + def test_schema_default_false(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + ) + assert cfg.training.nvfp4 is False + + def test_schema_happy(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {nvfp4: true}\n" + ) + assert cfg.training.nvfp4 is True + + def test_schema_rejects_mlx(self): + with pytest.raises(ValueError, match="mlx"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "backend: mlx\n" + "data: {train: x.jsonl}\n" + "training: {nvfp4: true}\n" + ) + + def test_schema_rejects_vision(self): + with pytest.raises(ValueError, match="text"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "modality: vision\n" + "data: {train: x.jsonl, format: llava}\n" + "training: {nvfp4: true}\n" + ) + + def test_apply_deferred(self): + from soup_cli.utils.advanced_precision import apply_nvfp4 + + with pytest.raises(NotImplementedError, match="v0.53.1"): + apply_nvfp4() + + +class TestUnslothBNB4Bit: + def test_compat_off_no_check(self): + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=False, backend="transformers", quantization="none", + ) + + def test_compat_happy(self): + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=True, backend="unsloth", quantization="4bit", + ) + + def test_compat_requires_unsloth(self): + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + with pytest.raises(ValueError, match="backend='unsloth'"): + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=True, backend="transformers", + quantization="4bit", + ) + + def test_compat_requires_4bit(self): + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + with pytest.raises(ValueError, match="quantization='4bit'"): + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=True, backend="unsloth", quantization="8bit", + ) + + def test_schema_happy(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "backend: unsloth\n" + "data: {train: x.jsonl}\n" + "training: {unsloth_bnb_4bit: true, quantization: 4bit}\n" + ) + assert cfg.training.unsloth_bnb_4bit is True + + def test_schema_rejects_non_unsloth(self): + with pytest.raises(ValueError, match="backend='unsloth'"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {unsloth_bnb_4bit: true, quantization: 4bit}\n" + ) + + +# --------------------------------------------------------------------------- +# Part E — LF / Axolotl parity +# --------------------------------------------------------------------------- + + +class TestLFParity: + def test_double_quant_default_false(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + ) + assert cfg.training.bnb_4bit_use_double_quant is False + + def test_double_quant_happy_with_4bit(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {bnb_4bit_use_double_quant: true, quantization: 4bit}\n" + ) + assert cfg.training.bnb_4bit_use_double_quant is True + + def test_double_quant_rejects_8bit(self): + with pytest.raises(ValueError, match="quantization='4bit'"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: " + "{bnb_4bit_use_double_quant: true, quantization: 8bit}\n" + ) + + def test_llm_int8_requires_8bit(self): + with pytest.raises(ValueError, match="quantization='8bit'"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {llm_int8: true, quantization: 4bit}\n" + ) + + def test_llm_int8_happy(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {llm_int8: true, quantization: 8bit}\n" + ) + assert cfg.training.llm_int8 is True + + def test_quantize_ref_model_happy_dpo(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: dpo\n" + "data: {train: x.jsonl, format: dpo}\n" + "training: {quantize_ref_model: true}\n" + ) + assert cfg.training.quantize_ref_model is True + + def test_quantize_ref_model_rejects_sft(self): + with pytest.raises(ValueError, match="reference model"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {quantize_ref_model: true}\n" + ) + + def test_quantize_reward_model_happy_ppo(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: ppo\n" + "data: {train: x.jsonl}\n" + "training: {quantize_reward_model: true, reward_model: r/m}\n" + ) + assert cfg.training.quantize_reward_model is True + + def test_quantize_reward_model_rejects_dpo(self): + with pytest.raises(ValueError, match="ppo|reward_model"): + load_config_from_string( + "base: a/b\n" + "task: dpo\n" + "data: {train: x.jsonl, format: dpo}\n" + "training: {quantize_reward_model: true}\n" + ) + + @pytest.mark.parametrize( + "field", [ + "fp8_attention", "nvfp4", "unsloth_bnb_4bit", + "bnb_4bit_use_double_quant", "llm_int8", + "quantize_ref_model", "quantize_reward_model", + ], + ) + def test_bool_guards_reject_int(self, field): + # The inner TypeError from `_validate_v053_bool_fields` propagates + # through Pydantic's field_validator(mode='before') unchanged. + with pytest.raises(TypeError, match="v0.53.0 flag must be bool"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + f"training: {{{field}: 7}}\n" + ) + + def test_quantize_ref_model_happy_grpo(self): + # Code-review HIGH fix — GRPO has a KL-to-ref policy so + # quantize_ref_model is meaningful on grpo. + cfg = load_config_from_string( + "base: a/b\n" + "task: grpo\n" + "data: {train: x.jsonl}\n" + "training: {quantize_ref_model: true, reward_fn: accuracy}\n" + ) + assert cfg.training.quantize_ref_model is True + + def test_quantize_ref_model_happy_kto(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: kto\n" + "data: {train: x.jsonl, format: kto}\n" + "training: {quantize_ref_model: true}\n" + ) + assert cfg.training.quantize_ref_model is True + + def test_quantize_ref_model_rejects_pretrain(self): + with pytest.raises(ValueError, match="reference model"): + load_config_from_string( + "base: a/b\n" + "task: pretrain\n" + "data: {train: x.txt, format: plaintext}\n" + "training: {quantize_ref_model: true}\n" + ) + + @pytest.mark.parametrize("quant", ["none", "8bit", "gptq"]) + def test_double_quant_rejects_non_4bit(self, quant): + with pytest.raises(ValueError, match="quantization='4bit'"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + f"training: {{bnb_4bit_use_double_quant: true, quantization: {quant}}}\n" + ) + + def test_llm_int8_rejects_default_none(self): + with pytest.raises(ValueError, match="quantization='8bit'"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {llm_int8: true}\n" + ) + + def test_quantize_reward_model_happy_reward_model_task(self): + cfg = load_config_from_string( + "base: a/b\n" + "task: reward_model\n" + "data: {train: x.jsonl, format: dpo}\n" + "training: {quantize_reward_model: true}\n" + ) + assert cfg.training.quantize_reward_model is True + + def test_v053_bool_field_explicit_null_rejected(self): + # Review fix — `None` should NOT silently coerce to False on a + # non-optional bool field. Pydantic rejects with "valid boolean" + # error so a YAML typo like `fp8_attention: ~` is surfaced loudly + # rather than masquerading as `False`. + with pytest.raises(ValueError, match="valid boolean"): + load_config_from_string( + "base: a/b\n" + "task: sft\n" + "data: {train: x.jsonl}\n" + "training: {fp8_attention: null}\n" + ) + + +# --------------------------------------------------------------------------- +# Part F — Save formats (merge 4bit + torchao export) +# --------------------------------------------------------------------------- + + +class TestSaveFormats: + def test_merge_save_formats_frozenset(self): + from soup_cli.utils.save_formats import MERGE_SAVE_FORMATS + + assert isinstance(MERGE_SAVE_FORMATS, frozenset) + assert MERGE_SAVE_FORMATS == {"fp16", "4bit", "4bit_forced"} + + def test_torchao_schemes_frozenset(self): + from soup_cli.utils.save_formats import TORCHAO_PTQ_SCHEMES + + assert isinstance(TORCHAO_PTQ_SCHEMES, frozenset) + assert "Int4WeightOnly" in TORCHAO_PTQ_SCHEMES + assert "NVFP4" in TORCHAO_PTQ_SCHEMES + + @pytest.mark.parametrize( + "name", ["fp16", "FP16", "4bit", "4BIT", "4bit_forced"], + ) + def test_validate_merge_canonical(self, name): + from soup_cli.utils.save_formats import validate_merge_save_format + + assert validate_merge_save_format(name) == name.lower() + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("4bit\x00", ValueError), + ("x" * 100, ValueError), + ("2bit", ValueError), + ], + ) + def test_validate_merge_rejects(self, bad, exc): + from soup_cli.utils.save_formats import validate_merge_save_format + + with pytest.raises(exc): + validate_merge_save_format(bad) + + def test_validate_torchao_case_sensitive(self): + from soup_cli.utils.save_formats import validate_torchao_scheme + + assert validate_torchao_scheme("Int4WeightOnly") == "Int4WeightOnly" + with pytest.raises(ValueError, match="not supported"): + validate_torchao_scheme("int4weightonly") + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (123, TypeError), + ("", ValueError), + ("X\x00Y", ValueError), + ("Unknown", ValueError), + ], + ) + def test_validate_torchao_rejects(self, bad, exc): + from soup_cli.utils.save_formats import validate_torchao_scheme + + with pytest.raises(exc): + validate_torchao_scheme(bad) + + def test_quant_config_path(self): + from soup_cli.utils.save_formats import validate_quant_config_path + + assert validate_quant_config_path("cfg.yaml") == "cfg.yaml" + + @pytest.mark.parametrize( + "bad,exc", [ + (True, TypeError), + (None, TypeError), + ("", ValueError), + ("a\x00b", ValueError), + ], + ) + def test_quant_config_path_rejects(self, bad, exc): + from soup_cli.utils.save_formats import validate_quant_config_path + + with pytest.raises(exc): + validate_quant_config_path(bad) + + def test_spec_frozen(self): + from soup_cli.utils.save_formats import ( + get_merge_save_spec, + get_torchao_spec, + ) + + m = get_merge_save_spec("4bit") + t = get_torchao_spec("NVFP4") + assert m.bits == 4 + assert t.bits == 4 + with pytest.raises(Exception): + m.bits = 8 # type: ignore[misc] + with pytest.raises(Exception): + t.live_wired = True # type: ignore[misc] + + def test_merge_metadata_immutable(self): + from soup_cli.utils.save_formats import ( + _MERGE_METADATA, + _TORCHAO_METADATA, + ) + + assert isinstance(_MERGE_METADATA, MappingProxyType) + assert isinstance(_TORCHAO_METADATA, MappingProxyType) + + def test_merge_4bit_deferred(self): + from soup_cli.utils.save_formats import merge_4bit + + with pytest.raises(NotImplementedError, match="v0.53.1"): + merge_4bit() + + def test_export_torchao_deferred(self): + from soup_cli.utils.save_formats import export_torchao + + with pytest.raises(NotImplementedError, match="v0.53.1"): + export_torchao() + + +# --------------------------------------------------------------------------- +# Cross-cutting — round-trip + immutability +# --------------------------------------------------------------------------- + + +class TestCrossCutting: + def test_full_v053_roundtrip(self): + yaml_text = ( + "base: a/b\n" + "task: sft\n" + "backend: unsloth\n" + "data: {train: x.jsonl}\n" + "training:\n" + " quantization: 4bit\n" + " quantization_aware: fp8\n" + " fp8_attention: true\n" + " kv_cache_type: q8_0\n" + " unsloth_bnb_4bit: true\n" + " bnb_4bit_use_double_quant: true\n" + ) + cfg = load_config_from_string(yaml_text) + assert cfg.training.fp8_attention is True + assert cfg.training.kv_cache_type == "q8_0" + assert cfg.training.unsloth_bnb_4bit is True + assert cfg.training.bnb_4bit_use_double_quant is True + + def test_gguf_metadata_immutable(self): + from soup_cli.utils.gguf_quant import _GGUF_METADATA + + assert isinstance(_GGUF_METADATA, MappingProxyType) + with pytest.raises(TypeError): + _GGUF_METADATA["evil"] = None # type: ignore[index] + + def test_total_advanced_gguf_count(self): + from soup_cli.utils.gguf_quant import ( + ALL_ADVANCED_GGUF_FORMATS, + APPLE_ARM_GGUF_FORMATS, + IQ_GGUF_FORMATS, + UD_GGUF_FORMATS, + ) + + assert len(ALL_ADVANCED_GGUF_FORMATS) == ( + len(UD_GGUF_FORMATS) + + len(IQ_GGUF_FORMATS) + + len(APPLE_ARM_GGUF_FORMATS) + ) + + +class TestReviewFollowups: + """Coverage-gap fixes from the v0.53.0 review round.""" + + # --- gguf_quant ---------------------------------------------------------- + def test_get_gguf_spec_unknown_raises(self): + from soup_cli.utils.gguf_quant import get_gguf_spec + + with pytest.raises(ValueError, match="not in v0.53.0 catalog"): + get_gguf_spec("Q4_0") # legacy llama.cpp, not in v0.53.0 allowlist + + def test_get_gguf_spec_non_string_raises(self): + from soup_cli.utils.gguf_quant import get_gguf_spec + + with pytest.raises(TypeError, match="str"): + get_gguf_spec(123) # type: ignore[arg-type] + + def test_calibration_path_exact_boundary(self): + from soup_cli.utils.gguf_quant import validate_calibration_data_path + + # 4096 chars — accepted at boundary + ok = "a" * 4096 + assert validate_calibration_data_path(ok) == ok + # 4097 chars — rejected + with pytest.raises(ValueError, match="too long"): + validate_calibration_data_path("a" * 4097) + + def test_lower_index_immutable(self): + from soup_cli.utils.gguf_quant import _LOWER_INDEX + + assert isinstance(_LOWER_INDEX, MappingProxyType) + with pytest.raises(TypeError): + _LOWER_INDEX["evil"] = "x" # type: ignore[index] + + # --- kv_cache ------------------------------------------------------------ + def test_get_kv_cache_spec_unknown(self): + from soup_cli.utils.kv_cache import get_kv_cache_spec + + with pytest.raises(ValueError, match="not supported"): + get_kv_cache_spec("int8") + + def test_requires_hopper_reads_from_spec(self): + # requires_hopper now delegates to _KV_CACHE_METADATA so a future + # spec edit would be picked up automatically. + from soup_cli.utils.kv_cache import ( + _KV_CACHE_METADATA, + requires_hopper, + ) + + for name, spec in _KV_CACHE_METADATA.items(): + assert requires_hopper(name) is spec.requires_hopper + + # --- advanced_precision bool guards on string params -------------------- + def test_fp8_attention_backend_bool_rejected(self): + from soup_cli.utils.advanced_precision import ( + validate_fp8_attention_compat, + ) + + with pytest.raises(TypeError, match="backend must not be bool"): + validate_fp8_attention_compat( + fp8_attention=True, + quantization_aware="fp8", + backend=True, # type: ignore[arg-type] + ) + + def test_nvfp4_backend_bool_rejected(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + with pytest.raises(TypeError, match="backend must not be bool"): + validate_nvfp4_compat( + nvfp4=True, + backend=True, # type: ignore[arg-type] + modality="text", + ) + + def test_nvfp4_modality_bool_rejected(self): + from soup_cli.utils.advanced_precision import validate_nvfp4_compat + + with pytest.raises(TypeError, match="modality must not be bool"): + validate_nvfp4_compat( + nvfp4=True, + backend="transformers", + modality=True, # type: ignore[arg-type] + ) + + def test_unsloth_bnb_backend_bool_rejected(self): + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + with pytest.raises(TypeError, match="backend must not be bool"): + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=True, + backend=True, # type: ignore[arg-type] + quantization="4bit", + ) + + def test_unsloth_bnb_quantization_bool_rejected(self): + from soup_cli.utils.advanced_precision import ( + validate_unsloth_bnb_4bit_compat, + ) + + with pytest.raises(TypeError, match="quantization must not be bool"): + validate_unsloth_bnb_4bit_compat( + unsloth_bnb_4bit=True, + backend="unsloth", + quantization=True, # type: ignore[arg-type] + ) + + # --- save_formats boundary + symmetric behavior -------------------------- + def test_quant_config_path_exact_boundary(self): + from soup_cli.utils.save_formats import validate_quant_config_path + + ok = "a" * 4096 + assert validate_quant_config_path(ok) == ok + with pytest.raises(ValueError, match="too long"): + validate_quant_config_path("a" * 4097) + + def test_merge_save_format_normalises_to_lowercase(self): + from soup_cli.utils.save_formats import validate_merge_save_format + + assert validate_merge_save_format("4BIT_FORCED") == "4bit_forced" + assert validate_merge_save_format("FP16") == "fp16"