feat(lora): v0.39.0 — LoRA Quality (PiSSA + ReLoRA + per-pattern rank + surgical patches + templates registry)

Five PEFT-surface improvements that LlamaFactory and Axolotl maintain:

- LoraConfig.init_strategy Literal["random","pissa","olora"]; PiSSA SVD init
  via PEFT init_lora_weights="pissa". Back-compat: use_olora=True aligns to
  init_strategy="olora" via dict-copy model_validator(mode="before"); explicit
  conflict (use_olora=True + init_strategy="pissa"/"random") rejected.
  Mutual-exclusion vs DoRA / VeRA.

- ReLoRA callback (utils/relora.py): frozen ReLoRAPolicy with bounds-checked
  steps [1, 1e7] / warmup_ratio [0,1] / prune_ratio (0,1) (strict — prevents
  zero-everything footgun); magnitude_prune_tensor (in-place torch.kthvalue,
  rejects non-Tensor / single-element short-circuit); duck-typed
  ReLoRACallback (no transformers import at module load). TrainingConfig
  fields relora_steps / relora_warmup_ratio / relora_reset_optimizer /
  relora_prune_ratio. SoupConfig _validate_relora_supported_tasks gates to
  task=sft + transformers backend with distinct MLX-backend error message;
  multi-trainer expansion deferred to v0.39.1 (mirrors v0.27.0 MII /
  v0.37.0 multipack / v0.38.0 quant menu stub-then-live pattern).

- LoraConfig.rank_pattern / alpha_pattern Optional[Dict[str,int]]; field
  validator caps at 256 keys × value (0, 1024], rejects bool / null-byte /
  empty key. Cross-validator rejects with use_vera=True (VeRA shares one
  rank). peft_builder propagates into LoraConfig init_kwargs.

- utils/peft_patches.py: is_gemma4_model uses regex word boundary
  (?:^|[^a-z0-9])gemma-?4(?:[^a-z0-9]|$) so "ungemma4ed" no longer matches.
  apply_gemma4_clippable_patch swaps ClippableLinear → nn.Linear by class
  name (weight-copy fallback logs at DEBUG). strip_lora_dropout_for_3d_experts
  zeroes lora_dropout.p on 3-D weights (handles ModuleDict variant for
  PEFT >=0.10). apply_surgical_patches orchestrator validates model_name.
  Wired into sft.py _setup_transformers with is_gemma4_model gate before
  the pre-LoRA swap; post-LoRA 3-D dropout strip runs unconditionally
  (architecture-detected internally).

- 16 inline templates migrated to soup_cli/templates/*.yaml + manifest.json
  + load_template loader (path-traversal-rejecting name validator;
  os.path.realpath + commonpath containment so a tampered manifest cannot
  read files outside the package directory; 256 KB file-size cap with
  inline fallback). Inline TEMPLATES kept with deprecation comment
  (planned removal v0.41.0+); test_templates_yaml asserts byte-equality
  of all 16 inline ↔ YAML pairs to prevent silent drift.

Net +164 tests (4374 → 4538). All 5 review-agent waves clean before tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-01 16:18:07 +05:00
parent 6361f7cca4
commit e6a9c087c3
34 changed files with 2146 additions and 23 deletions

View File

@ -107,10 +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
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
templates/ - 16 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0)
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (122 files, 4435 tests)
tests/ - Test suite (132 files, 4538 tests)
examples/ - Real-world config examples and datasets
```

View File

@ -40,16 +40,14 @@ soup train
Latest highlights only. Full history: [GitHub Releases](https://github.com/MakazhanAlpamys/Soup/releases).
**v0.38.0 — Quant Menu**: 7 new train-time quantization formats. Train LoRA on top of any pre-quantized base model — close the width gap with LlamaFactory.
**v0.39.0 — LoRA Quality**: PEFT surface improvements that LlamaFactory and Axolotl maintain. Faster init, periodic adapter refresh, per-module rank, surgical architecture patches, and a cleaner template registry.
- **GPTQ / AWQ / HQQ / AQLM / EETQ / MXFP4 / FP8** — set `training.quantization` to one of `gptq` / `awq` / `hqq:1bit`..`hqq:8bit` / `aqlm` / `eetq` / `mxfp4` / `fp8`. Loaded as `quantization_config` on the underlying `from_pretrained` call; LoRA trains on top.
- **HQQ wide bit range** — 1, 2, 3, 4, 5, 6, or 8 bits via `hqq:Nbit` syntax. `hqq:7bit` is intentionally rejected (HQQ does not support it).
- **Compatibility matrix enforced at startup**`check_quant_distributed_compat` hard-fails HQQ/EETQ/AQLM × {FSDP, ZeRO-3} (sourced from LlamaFactory `quantization.py:199/211`). BNB-4bit + FSDP without `bnb_4bit_quant_storage` emits a yellow warning before the silent 2-3x perf cliff hits.
- **`bnb_4bit_quant_storage` field** — set to `bfloat16` / `float16` for the canonical FSDP+QLoRA combo ("crucial for fsdp+qlora" — LlamaFactory `quantization.py:178`). Schema rejects the setting on any non-BNB-4bit format.
- **`docs/QUANTIZATION.md`** — full compatibility matrix (format × {DDP / FSDP / ZeRO-1 / ZeRO-2 / ZeRO-3}) with per-format optional dep + use case.
- **Pre-quantized + QAT mutually exclusive** — every pre-quantized format combined with `quantization_aware` (int8 QAT or `'fp8'`) is rejected at config-load with the actual reason.
- **v0.38.0 scope** — wired into the SFT trainer + transformers backend. Multi-trainer expansion (DPO/GRPO/KTO/...) tracked for v0.38.1, mirroring v0.27.0 MII / v0.37.0 multipack stub-then-live pattern.
- **Net +61 tests** (4374 → 4435) covering schema acceptance, builder shape, checkpoint validators, cross-validators, the 7-row × 5-column compat matrix, modality/task/backend gate coverage, adversarial inputs (oversized HQQ suffix, bool group_size, hyphenated DeepSpeed presets), and the central loader entry point.
- **PiSSA init** — set `training.lora.init_strategy: pissa` for SVD-initialized LoRA pairs. Faster early convergence vs random init at the cost of one extra SVD pass on the first epoch. `init_strategy: olora` is also accepted (equivalent to legacy `use_olora=True`, which auto-aligns for back-compat).
- **ReLoRA callback** — set `training.relora_steps: 500` to magnitude-prune LoRA adapter weights every 500 steps and clear optimizer state. Useful for very long training runs where the LoRA capacity saturates. Bounds: `relora_warmup_ratio` [0,1], `relora_prune_ratio` (0,1), `relora_reset_optimizer: true`. Wired into the SFT trainer; multi-trainer expansion deferred to v0.39.1.
- **Per-pattern LoRA rank**`training.lora.rank_pattern: {q_proj: 8, v_proj: 16}` and `alpha_pattern` give different ranks per target module pattern. Useful in MoE configs where expert FFNs need lower rank than attention. Caps: 256 keys × value 1024.
- **Surgical PEFT patches** — Gemma 4 `ClippableLinear` is auto-swapped to plain `nn.Linear` so PEFT's matcher recognises it; fused-MoE 3-D expert weights have `lora_dropout` zeroed to dodge `ParamWrapper` crashes. Both are gated by architecture detection (regex word-boundary on the model name) and never run on unrelated models.
- **Template registry** — the 16 built-in templates now live as `soup_cli/templates/*.yaml` with a `manifest.json` index. `soup init --template <name>` reads the YAML; the inline copies in `schema.py` stay as a back-compat fallback (deprecation pointing at v0.41.0+).
- **Net +164 tests** (4374 → 4538) across PiSSA mutual-exclusion, ReLoRA frozen-policy + real-optimizer-state reset, rank_pattern bounds + null-byte rejection, regex word-boundary Gemma 4 detection, and template-registry path-traversal + manifest-tampering containment.
## Why Soup?
@ -632,6 +630,52 @@ training:
Replaces the static memory formula with a real try-halve-then-double-to-ceiling loop. Picked size is cached at `~/.soup/batch_cache.json` keyed on `(model, max_length, quantization, lora_r, gpu_name, gpu_memory_gb)` so repeat runs short-circuit.
## LoRA Quality — PiSSA, ReLoRA, Per-Pattern Rank, Surgical Patches
Five PEFT-surface improvements that LlamaFactory and Axolotl maintain:
```yaml
training:
lora:
init_strategy: pissa # 'random' (default), 'pissa', 'olora'
rank_pattern: # per-target-module rank override
q_proj: 8
v_proj: 16
alpha_pattern: # per-target-module alpha override
q_proj: 16
relora_steps: 500 # magnitude-prune LoRA every 500 steps
relora_warmup_ratio: 0.1 # skip first 10% of training
relora_prune_ratio: 0.9 # zero out smallest 90% by magnitude
relora_reset_optimizer: true # clear optimizer state on each fire
```
**PiSSA** initializes the LoRA pair from the SVD of the base weight, giving faster
early convergence than random init at the cost of one extra SVD pass on the first
epoch. `init_strategy: olora` is also accepted; setting the legacy `use_olora: true`
auto-aligns for back-compat.
**ReLoRA** fires every N global steps, magnitude-prunes the LoRA adapter weights
(keeping the top `1 - relora_prune_ratio` by absolute value), and optionally clears
optimizer state for the pruned parameters so momentum doesn't fight the new sparse
weights. Useful for very long training runs where the LoRA capacity saturates.
**Per-pattern rank/alpha** map module name patterns to integer ranks. Useful in MoE
configs where expert FFNs need lower rank than attention. Caps: 256 keys × value 1024.
**Surgical patches** (Gemma 4 `ClippableLinear` swap, fused-MoE 3-D expert
`lora_dropout` strip) auto-fire when the model name and architecture match. Both are
gated and silent on unrelated models.
**Template registry** — the 16 built-in templates now live as
`soup_cli/templates/*.yaml` with a `manifest.json` index. `soup init --template <name>`
reads the YAML; the inline copies in `schema.py` stay as a back-compat fallback,
deprecated in favour of the YAML registry.
**v0.39.0 scope** — wired into the SFT trainer + transformers backend.
Multi-trainer expansion of ReLoRA (DPO/GRPO/KTO/...) is tracked for v0.39.1
(mirrors v0.27.0 MII / v0.37.0 multipack / v0.38.0 quant menu stub-then-live
pattern). MLX backend gets a distinct error message.
## DPO Training
Train with preference data using Direct Preference Optimization:

View File

@ -9,9 +9,9 @@ We provide security updates for the following versions:
- **Versions older than 3 minor versions:** No support
Example:
- v0.38.0-0.38.x -- Full support (latest)
- v0.37.0-0.37.x -- Bug-fix support only
- v0.36.x and below -- No support
- v0.39.0-0.39.x -- Full support (latest)
- v0.38.0-0.38.x -- Bug-fix support only
- v0.37.x and below -- No support
## Reporting a Vulnerability
@ -142,6 +142,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.39.0 — LoRA Quality**: `LoraConfig.init_strategy: Literal["random","pissa","olora"]` rejects unknown strategies; PiSSA + DoRA / VeRA combinations rejected at config-load. `model_validator(mode="before")` aligns `use_olora=True``init_strategy="olora"` via dict-copy (no caller mutation; matches v0.33.0 #47 immutability policy). `rank_pattern`/`alpha_pattern: Optional[Dict[str, int]]` capped at 256 keys × value (0, 1024], rejects `bool` (subclass of `int` — matches v0.30.0 `Candidate` policy), null bytes in keys, empty keys; cross-validator rejects with `use_vera=True`. `ReLoRAPolicy` is `@dataclass(frozen=True)` (post-construction mutation raises `FrozenInstanceError`); bounds: `steps ∈ [1, 1e7]`, `warmup_ratio ∈ [0, 1]`, `prune_ratio ∈ (0, 1)` (strict — prevents zero-everything footgun). `magnitude_prune_tensor` strict `0 < prune_ratio < 1` rejection, non-Tensor input raises `TypeError`, empty / single-element tensor short-circuits (avoids `kthvalue(_, 0)` runtime crash). `_validate_relora_supported_tasks` cross-validator rejects `relora_steps` with `task != "sft"` and `backend=mlx` with distinct error messages (matches v0.34.0 distinct-reason policy); multi-trainer expansion deferred to v0.39.1. `is_gemma4_model` uses a word-boundary regex (`(?:^|[^a-z0-9])gemma-?4(?:[^a-z0-9]|$)`) so `"ungemma4ed"` / `"my-gemma4ish"` no longer over-match; null-byte rejection on `model_name`. `apply_gemma4_clippable_patch` weight-copy fallback logs at DEBUG instead of silent random-init; the patch is gated by `is_gemma4_model(cfg.base)` in `sft.py` before invocation so non-Gemma4 trainings never traverse the module tree. `apply_surgical_patches` rejects empty / null-byte `model_name` with `ValueError`. `templates/load_template` containment: filename re-validated via `_validate_name` (rejects `..`/`/`/`\\`/null/empty); `os.path.realpath + os.path.commonpath` containment check on the resolved path against `_templates_dir()` so a tampered `manifest.json` cannot read files outside the package directory (mirrors v0.26.0 registry policy). Tampered-manifest `ValueError` from `_validate_name` caught and falls back to inline (no propagating exception). 256 KB file-size cap. Inline `TEMPLATES` carries an explicit deprecation comment pointing at the canonical YAML registry; `tests/test_templates_yaml.py` asserts byte-equality of all 16 inline ↔ YAML pairs to prevent silent drift. Planned removal: v0.41.0+.
- **v0.38.0 — Quant Menu**: `TrainingConfig.quantization` Literal extended with `gptq` / `awq` / `hqq:1bit`..`hqq:8bit` (no `hqq:7bit` — HQQ doesn't support it) / `aqlm` / `eetq` / `mxfp4` / `fp8`; Pydantic rejects every other string at config-load. `validate_gptq_checkpoint` and `validate_awq_checkpoint` probe local paths for `quantize_config.json` / `quant_config.json`; HF repo IDs fall through; null-byte rejection + non-string `TypeError` on the ref. `_validate_prequantized_no_qat` rejects every pre-quantized format combined with `quantization_aware` (int8 QAT or `'fp8'`) — pre-quantized weights carry their own scale and QAT/FP8 prepare would silently corrupt them (mirrors LlamaFactory `quantization.py:117/199/211`). `_validate_bnb_quant_storage_only_with_4bit` rejects `bnb_4bit_quant_storage` on every non-BNB-4bit format (silent no-op otherwise); allowed dtypes: `Literal["uint8", "float16", "bfloat16", "float32"]`. `_validate_quant_menu_supported_tasks` restricts the new formats to `task='sft'` on `backend='transformers'` in v0.38.0 with distinct MLX-backend vs unsupported-task error messages (matches v0.34.0 distinct-reason policy). `check_quant_distributed_compat` hard-fails HQQ/EETQ/AQLM × {FSDP, ZeRO-3} (sourced from LlamaFactory `quantization.py:199/211` plus AQLM dequant constraints); warning-tier (not error) for BNB-4bit + FSDP without `bnb_4bit_quant_storage` so users see the silent perf cliff; unknown `quantization` raises `ValueError` (no silent pass) and the check is wired into `commands/train.py` startup. `parse_hqq_bits` rejects unsupported bit-rates and malformed `hqq:` strings before any kernel build.
- **v0.37.0 — Multipack**: `validate_multipack_architecture` raises `ValueError` on unknown arch (loud-fail vs Axolotl's silent-miss footgun); 18-arch frozen allowlist (Llama 3.x / Qwen 2/3 / Mistral / Gemma 2/3 / Phi 3/4 / DeepSeek V2/V3 / Mixtral / Falcon / StableLM / SmolLM2). FFD packer caps at `_MAX_FFD_ITEMS=1_000_000` (algorithm is O(N²) worst-case — defence against adversarial dataset DoS); `bool` rejection on every numeric input (`max_len`, per-element `lengths`, `batch_max_len`, `batch_size`, `seed`, `max_seq_length`) matches v0.30.0+ `Candidate` policy; generator-input materialisation prevents silent empty-bin output when validation exhausts the iterator. `MultipackBatchSampler` rejects empty `lengths`, non-positive `batch_max_len`/`batch_size`, items larger than `batch_max_len`. `build_multipack_sampler_for_lengths` rejects `tcfg.batch_size="auto"` with actionable message (must be resolved upstream). `_validate_multipack_packing_exclusive` cross-validator on `TrainingConfig` rejects both `multipack` and `packing` set; `_validate_multipack_supported_tasks` on `SoupConfig` restricts multipack to `sft`/`pretrain` on `transformers` backend with distinct error messages for MLX backend vs unsupported task (matches v0.34.0 distinct-reason policy). `build_4d_attention_mask` caps allocations at `_MAX_MASK_ELEMENTS=2³¹` cells (~8GB float32) — defence against `max_length=1M` × `batch_size=8` OOM; `tag_sub_sequences` capped at `_MAX_BOUNDARY_SEGMENTS=1_000_000`. Mask builder rejects non-floating dtypes (was silent `np.finfo` ValueError), non-2D `seq_pos_ids`, negative segment IDs; padding (id=0) tokens are fully masked, including diagonal, so softmax is well-defined. `select_packing_strategy` rejects non-bool `flash_attn_available`. `JinjaTemplateAnalyzer` parses chat templates via `Environment.parse` only — never renders, so a crafted soup.yaml cannot trigger SSRF / filesystem reads; 128KB template cap, null-byte rejection, `TemplateSyntaxError` re-raised as `ValueError`. `DEFAULT_MESSAGE_FIELDS` is a `frozenset` (runtime-immutable); `JinjaTemplateAnalyzer.message_fields` returns a defensive copy.
- **v0.36.0 — Correctness First**: `--trust-remote-code` opt-in replaces 9 unconditional `trust_remote_code=True` call sites across `soup train` / `chat` / `serve` / `data download` / `eval auto`; `KNOWN_SAFE_PREFIXES` allowlist (15 first-party orgs) suppresses warning panel for trusted repos; `model_requires_trust_remote_code` probes local `config.json` for `auto_map` (HF Hub repo IDs return `None`/unknown — HF still raises loudly when custom code is actually needed); `resolve_trust_remote_code` raises `ValueError` with actionable message when model needs custom code but the user did not opt in. Chat-template hardening: `DataConfig.chat_template` validator rejects null bytes, oversize (>64KB), AND filesystem-touching Jinja directives (`{% include %}`, `{% import %}`, `{% from %}`, `{% macro %}`, `{% extends %}` — both whitespace-control variants); empty string normalised to `None`; `_REGISTRY` wrapped in `MappingProxyType` (callers cannot mutate); `apply_chat_template_override` emits yellow advisory when active so users know `soup push` will persist the override into `tokenizer_config.json`. SFT silent f-string fallback (`f"{role}: {content}"`) replaced with hard `ValueError` — produced wrong loss labels for years on tokenizers without a chat template. Loss-mask fallback passes `add_special_tokens=False` to incremental tokenize calls so HF cannot double-prepend BOS at front of each render (consistent prefix-delta walk); narrowed exception catch in `_apply_template_with_mask` from `(TypeError, ValueError)` to `TypeError` only so a malformed messages list propagates instead of falling through to the loose path. `SOUP_BATCH_CACHE_PATH` env override containment-checked via `os.path.realpath + commonpath` against `~`/cwd/`tempfile.gettempdir()`; out-of-bounds values fall through to safe default; cache file gets best-effort `0o600` perms after atomic rename (matches v0.26.0 registry.db policy). `make_cache_key` rejects `bool` in numeric inputs (matches v0.30.0 `Candidate` policy). Documented limitation: non-SFT trainers (DPO/GRPO/KTO/ORPO/SimPO/IPO/PPO/RewardModel/Pretrain/Embedding) and `commands/{diff,export,merge,infer,generate}.py` still hardcode `trust_remote_code=True` — v0.36.x patch follow-up.

View File

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

View File

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

View File

@ -7,7 +7,7 @@ from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
from soup_cli.config.schema import TEMPLATES
from soup_cli.templates import list_templates, load_template
console = Console()
@ -36,11 +36,11 @@ def init(
raise typer.Exit()
if template:
if template not in TEMPLATES:
config_text = load_template(template)
if config_text is None:
console.print(f"[red]Unknown template: {template}[/]")
console.print(f"Available: {', '.join(TEMPLATES.keys())}")
console.print(f"Available: {', '.join(list_templates())}")
raise typer.Exit(1)
config_text = TEMPLATES[template]
console.print(f"[green]Using template:[/] {template}")
else:
config_text = _interactive_wizard()

View File

@ -1,10 +1,14 @@
"""Pydantic schemas for soup.yaml config — single source of truth."""
import re
from typing import List, Literal, Optional, Union
from typing import Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Field, field_validator, model_validator
# v0.39.0 Part C — per-pattern LoRA rank/alpha bounds
_MAX_LORA_RANK_PATTERN_KEYS = 256
_MAX_LORA_RANK_PATTERN_VALUE = 1024
class LoraConfig(BaseModel):
r: int = Field(default=64, description="LoRA rank")
@ -35,7 +39,35 @@ class LoraConfig(BaseModel):
description=(
"Enable OLoRA (Orthogonal LoRA init via QR decomposition). "
"Passes init_lora_weights='olora' to peft. "
"Mutually exclusive with use_dora and use_vera."
"Mutually exclusive with use_dora and use_vera. "
"Equivalent to init_strategy='olora'."
),
)
rank_pattern: Optional[Dict[str, int]] = Field(
default=None,
description=(
"Per-target-module-pattern LoRA rank override. Maps module name "
"patterns (e.g. 'q_proj', 'experts.*.w1') to integer rank values. "
"Useful for MoE configs where expert FFNs need lower rank than attn. "
"Incompatible with use_vera (VeRA shares one rank across modules)."
),
)
alpha_pattern: Optional[Dict[str, int]] = Field(
default=None,
description=(
"Per-target-module-pattern LoRA alpha override. Maps module name "
"patterns to integer alpha values. Pairs with rank_pattern. "
"Incompatible with use_vera."
),
)
init_strategy: Literal["random", "pissa", "olora"] = Field(
default="random",
description=(
"LoRA init strategy. 'random' (default) is standard Kaiming init. "
"'pissa' (PiSSA) initializes A/B from the SVD of the base weight — "
"faster early convergence but adds an SVD pass on the first epoch. "
"'olora' is equivalent to use_olora=True (orthogonal QR init). "
"Cannot be combined with use_dora or use_vera."
),
)
@ -57,6 +89,85 @@ class LoraConfig(BaseModel):
)
return self
@model_validator(mode="before")
@classmethod
def _backcompat_align_olora(cls, values):
"""Back-compat: pre-validation, align init_strategy='olora' when only use_olora was set."""
if not isinstance(values, dict):
return values
# Copy to avoid mutating the caller's dict (matches v0.33.0 #47
# CrossDocCollator immutability fix).
if values.get("use_olora") and "init_strategy" not in values:
values = dict(values)
values["init_strategy"] = "olora"
return values
@model_validator(mode="after")
def _validate_init_strategy(self) -> "LoraConfig":
# use_olora=True must agree with init_strategy when both are explicit
if self.use_olora and self.init_strategy != "olora":
raise ValueError(
f"use_olora=True conflicts with init_strategy={self.init_strategy!r}. "
f"Either set init_strategy='olora' (or omit it), or set use_olora=False."
)
# init_strategy='pissa' is incompatible with DoRA / VeRA
if self.init_strategy == "pissa" and (self.use_dora or self.use_vera):
other = "use_dora" if self.use_dora else "use_vera"
raise ValueError(
f"init_strategy='pissa' is incompatible with {other}=True. "
f"PiSSA initializes the LoRA pair via SVD; combine with plain LoRA "
f"(or rsLoRA) only."
)
return self
@field_validator("rank_pattern", "alpha_pattern", mode="before")
@classmethod
def _validate_pattern_dict(cls, value) -> Optional[Dict[str, int]]:
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("rank_pattern/alpha_pattern must be a dict[str, int]")
if len(value) > _MAX_LORA_RANK_PATTERN_KEYS:
raise ValueError(
f"rank_pattern/alpha_pattern caps at {_MAX_LORA_RANK_PATTERN_KEYS} keys, "
f"got {len(value)}"
)
cleaned: Dict[str, int] = {}
for key, val in value.items():
if not isinstance(key, str) or not key:
raise ValueError(
"rank_pattern/alpha_pattern keys must be non-empty strings"
)
if "\x00" in key:
raise ValueError("rank_pattern/alpha_pattern keys cannot contain null bytes")
if isinstance(val, bool) or not isinstance(val, int):
raise ValueError(
f"rank_pattern/alpha_pattern values must be int, "
f"got {type(val).__name__} for {key!r}"
)
if val <= 0 or val > _MAX_LORA_RANK_PATTERN_VALUE:
raise ValueError(
f"rank_pattern/alpha_pattern values must be in (0, "
f"{_MAX_LORA_RANK_PATTERN_VALUE}], got {val} for {key!r}"
)
cleaned[key] = val
return cleaned
@model_validator(mode="after")
def _validate_pattern_vera_exclusivity(self) -> "LoraConfig":
if self.use_vera and self.rank_pattern:
raise ValueError(
"rank_pattern is incompatible with use_vera=True (VeRA shares "
"a single rank across all target modules). Disable use_vera or "
"remove rank_pattern."
)
if self.use_vera and self.alpha_pattern:
raise ValueError(
"alpha_pattern is incompatible with use_vera=True. Disable "
"use_vera or remove alpha_pattern."
)
return self
class DataConfig(BaseModel):
train: str = Field(..., description="Path to training data or HF dataset name")
@ -486,6 +597,29 @@ class TrainingConfig(BaseModel):
default=0.5, gt=0.0, lt=1.0,
description="Multiply LR by this factor on each spike recovery (0.5 = halve)",
)
# ReLoRA (v0.39.0 Part B)
relora_steps: Optional[int] = Field(
default=None, ge=1, le=10**7,
description=(
"Fire ReLoRA magnitude-prune + optimizer reset every N global steps. "
"None disables. Requires a LoRA-style PEFT (not VeRA)."
),
)
relora_warmup_ratio: float = Field(
default=0.1, ge=0.0, le=1.0,
description="Skip ReLoRA firings during the first warmup_ratio fraction of training",
)
relora_reset_optimizer: bool = Field(
default=True,
description="Clear optimizer state for pruned LoRA params on each ReLoRA fire",
)
relora_prune_ratio: float = Field(
default=0.9, gt=0.0, lt=1.0,
description=(
"Fraction of LoRA weights to zero out by magnitude on each fire "
"(0.9 keeps the top 10%). Must be < 1.0."
),
)
# Convergence detection (v0.32.0 Part F)
convergence_detection: bool = Field(
default=False,
@ -865,6 +999,31 @@ class SoupConfig(BaseModel):
)
return self
@model_validator(mode="after")
def _validate_relora_supported_tasks(self) -> "SoupConfig":
"""v0.39.0 — ReLoRA callback is wired in the SFT trainer only.
Multi-trainer expansion (DPO / GRPO / KTO / ORPO / SimPO / IPO /
PPO / RewardModel / Pretrain / Embedding) deferred to v0.39.1
(mirrors v0.27.0 MII / v0.37.0 multipack / v0.38.0 quant menu
stub-then-live pattern).
"""
if self.training.relora_steps is None:
return self
if self.backend == "mlx":
raise ValueError(
"relora_steps is not supported on the mlx backend "
"in v0.39.0 (callback is HF Trainer-specific). "
"Use backend='transformers' or remove relora_steps."
)
if self.task != "sft":
raise ValueError(
f"relora_steps is wired only for task='sft' in v0.39.0, "
f"got task={self.task!r}. Multi-trainer expansion is tracked "
"as a known limitation; remove relora_steps or set task='sft'."
)
return self
@model_validator(mode="after")
def _validate_quant_menu_supported_tasks(self) -> "SoupConfig":
"""v0.38.0 — Quant Menu (gptq/awq/hqq:Nbit/aqlm/eetq/mxfp4/fp8) is wired
@ -919,6 +1078,11 @@ class SoupConfig(BaseModel):
# --- Built-in templates ---
# DEPRECATED (v0.39.0 Part E) — these inline templates are kept for back-compat.
# The canonical source is `soup_cli/templates/*.yaml` with `manifest.json`.
# Both sources are asserted equal in tests/test_templates_yaml.py — when editing
# a template, update both. Planned removal: v0.41.0+ once external consumers
# have migrated to the YAML registry.
TEMPLATES: dict[str, str] = {
"chat": """# Soup template: Chat Assistant
# Fine-tune a model for conversational chat

View File

@ -0,0 +1,110 @@
"""Soup template registry (v0.39.0 Part E).
Templates live as YAML files alongside this module. ``manifest.json``
declares which files exist; the loader resolves a template name to its
file content. Falls back to the inline ``TEMPLATES`` dict in
``soup_cli.config.schema`` for back-compat.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
# Cap to prevent symlink-based escapes or pathologically huge templates.
_MAX_TEMPLATE_BYTES = 256 * 1024
def _templates_dir() -> Path:
return Path(__file__).resolve().parent
def _load_manifest() -> dict:
path = _templates_dir() / "manifest.json"
if not path.is_file():
return {"templates": {}, "version": 1}
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return {"templates": {}, "version": 1}
if not isinstance(data, dict):
return {"templates": {}, "version": 1}
templates = data.get("templates")
if not isinstance(templates, dict):
templates = {}
return {"templates": templates, "version": int(data.get("version", 1))}
def _validate_name(name: str) -> None:
if not isinstance(name, str) or not name:
raise ValueError("Template name must be a non-empty string")
if "\x00" in name or "/" in name or "\\" in name or ".." in name:
raise ValueError(f"Invalid template name: {name!r}")
def list_templates() -> list[str]:
"""Return all known template names (YAML manifest + inline fallback)."""
names: set[str] = set()
manifest = _load_manifest()
names.update(manifest["templates"].keys())
# Fold in inline templates for back-compat.
try:
from soup_cli.config.schema import TEMPLATES as _INLINE
names.update(_INLINE.keys())
except ImportError:
pass
return sorted(names)
def load_template(name: str) -> Optional[str]:
"""Resolve a template name to its YAML body. Returns None if unknown.
Order:
1. YAML file in this directory (per ``manifest.json``).
2. Inline ``TEMPLATES`` dict in ``soup_cli.config.schema``.
"""
_validate_name(name)
manifest = _load_manifest()
filename = manifest["templates"].get(name)
if isinstance(filename, str) and filename:
# Re-validate filename to defeat manifest tampering. A tampered
# manifest pointing at "../secret.yaml" must not crash the loader —
# silently fall back to inline so callers see a degraded but safe
# result instead of a propagating ValueError.
try:
_validate_name(filename.replace(".yaml", ""))
except ValueError:
return _fallback_inline(name)
if not filename.endswith(".yaml"):
return _fallback_inline(name)
templates_dir_str = os.path.realpath(str(_templates_dir()))
path = _templates_dir() / filename
# Containment: defeat crafted manifest entries with extra dots / mixed
# separators that slip past _validate_name on Windows.
try:
real_path = os.path.realpath(str(path))
if os.path.commonpath([real_path, templates_dir_str]) != templates_dir_str:
return _fallback_inline(name)
except (OSError, ValueError):
return _fallback_inline(name)
try:
if path.is_file():
size = path.stat().st_size
if size > _MAX_TEMPLATE_BYTES:
return _fallback_inline(name)
with path.open("r", encoding="utf-8") as f:
return f.read()
except OSError:
pass
return _fallback_inline(name)
def _fallback_inline(name: str) -> Optional[str]:
try:
from soup_cli.config.schema import TEMPLATES as _INLINE
except ImportError:
return None
return _INLINE.get(name)

View File

@ -0,0 +1,34 @@
# Soup template: Audio / Speech
# Fine-tune an audio-language model for speech understanding
#
# Supported models: Qwen2-Audio, Whisper (via transformers)
#
# Data format (JSONL):
# {"audio": "path/to/audio.wav", "messages": [
# {"role": "user", "content": "Transcribe."},
# {"role": "assistant", "content": "Hello world."}]}
base: Qwen/Qwen2-Audio-7B-Instruct
task: sft
modality: audio
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/audio_train.jsonl
format: audio
audio_dir: ./data/audio
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 8
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
output: ./output_audio

View File

@ -0,0 +1,24 @@
# Soup template: Chat Assistant
# Fine-tune a model for conversational chat
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/train.jsonl
format: alpaca
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 2e-5
batch_size: auto
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
output: ./output

View File

@ -0,0 +1,24 @@
# Soup template: Code Model
# Fine-tune a model for code generation / completion
base: codellama/CodeLlama-7b-Instruct-hf
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/code_train.jsonl
format: alpaca
val_split: 0.1
max_length: 4096
training:
epochs: 2
lr: 1e-5
batch_size: auto
lora:
r: 128
alpha: 32
target_modules: auto
quantization: 4bit
output: ./output

View File

@ -0,0 +1,34 @@
# Soup template: Embedding Model Fine-tuning
# Fine-tune a sentence embedding model (BGE, E5, GTE, etc.)
#
# Data format (JSONL) — contrastive pairs:
# {"anchor": "What is Python?", "positive": "Python is a programming language."}
#
# Data format (JSONL) — triplets:
# {"anchor": "query", "positive": "relevant doc", "negative": "unrelated doc"}
base: BAAI/bge-base-en-v1.5
task: embedding
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/embedding_train.jsonl
format: embedding
val_split: 0.1
max_length: 512
training:
epochs: 3
lr: 2e-5
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 64
alpha: 16
target_modules: auto
quantization: none
embedding_loss: contrastive
embedding_margin: 0.5
embedding_pooling: mean
output: ./output_embedding

View File

@ -0,0 +1,29 @@
# Soup template: IPO (Identity Preference Optimization)
# A theoretically grounded variant of DPO with stronger regularization
#
# Data format (JSONL):
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
base: meta-llama/Llama-3.1-8B-Instruct
task: ipo
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/preference_train.jsonl
format: dpo
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
ipo_tau: 0.1
output: ./output

View File

@ -0,0 +1,30 @@
# Soup template: KTO (Kahneman-Tversky Optimization)
# Align a model using unpaired preference data (no need for chosen+rejected pairs)
#
# Data format (JSONL):
# {"prompt": "What is 2+2?", "completion": "4", "label": true}
# {"prompt": "What is 2+2?", "completion": "Fish", "label": false}
base: meta-llama/Llama-3.1-8B-Instruct
task: kto
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/kto_train.jsonl
format: kto
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
kto_beta: 0.1
output: ./output

View File

@ -0,0 +1,33 @@
# Soup template: Long-Context Fine-tuning (128k+)
# Extend model context window for long-document understanding
#
# Uses RoPE scaling + gradient checkpointing + FlashAttention for 128k tokens.
# Optionally enable Liger Kernel for additional memory savings.
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/long_context_train.jsonl
format: alpaca
val_split: 0.05
max_length: 131072
training:
epochs: 1
lr: 5e-6
batch_size: 1
gradient_accumulation_steps: 16
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
gradient_checkpointing: true
rope_scaling_type: dynamic
use_flash_attn: true
# use_liger: true # pip install 'soup-cli[liger]' for fused ops
# use_ring_attention: true # Multi-GPU sequence parallelism
output: ./output_longctx

View File

@ -0,0 +1,21 @@
{
"templates": {
"audio": "audio.yaml",
"chat": "chat.yaml",
"code": "code.yaml",
"embedding": "embedding.yaml",
"ipo": "ipo.yaml",
"kto": "kto.yaml",
"longcontext": "longcontext.yaml",
"medical": "medical.yaml",
"moe": "moe.yaml",
"orpo": "orpo.yaml",
"pretrain": "pretrain.yaml",
"reasoning": "reasoning.yaml",
"rlhf": "rlhf.yaml",
"simpo": "simpo.yaml",
"tool-calling": "tool-calling.yaml",
"vision": "vision.yaml"
},
"version": 1
}

View File

@ -0,0 +1,25 @@
# Soup template: Medical / Domain Expert
# Fine-tune a model with domain-specific knowledge
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/medical_train.jsonl
format: alpaca
val_split: 0.15
max_length: 2048
training:
epochs: 5
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 8
lora:
r: 128
alpha: 32
target_modules: auto
quantization: 4bit
output: ./output

View File

@ -0,0 +1,29 @@
# Soup template: MoE (Mixture of Experts) Fine-tuning
# Fine-tune a Mixture of Experts model with ScatterMoE LoRA
#
# Supported MoE models: Qwen3-30B-A3B, Mixtral-8x7B, DeepSeek-V3, etc.
base: Qwen/Qwen3-30B-A3B
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/train.jsonl
format: alpaca
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 8
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
moe_lora: true
moe_aux_loss_coeff: 0.01
output: ./output

View File

@ -0,0 +1,29 @@
# Soup template: ORPO (Odds Ratio Preference Optimization)
# Align a model without a reference model — simpler than DPO
#
# Data format (JSONL):
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
base: meta-llama/Llama-3.1-8B-Instruct
task: orpo
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/preference_train.jsonl
format: dpo
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
orpo_beta: 0.1
output: ./output

View File

@ -0,0 +1,30 @@
# Soup template: Continued Pre-training
# Continue pre-training a model on raw text data (domain adaptation)
#
# Data format (JSONL):
# {"text": "Your raw text document here..."}
#
# Or plain .txt files (one document per line or entire file as one document).
base: meta-llama/Llama-3.1-8B
task: pretrain
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/corpus.jsonl
format: plaintext
val_split: 0.05
max_length: 4096
training:
epochs: 1
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 8
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
output: ./output_pretrain

View File

@ -0,0 +1,28 @@
# Soup template: Reasoning / GRPO
# Fine-tune a model for chain-of-thought reasoning with GRPO
base: meta-llama/Llama-3.1-8B-Instruct
task: grpo
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/reasoning_train.jsonl
format: sharegpt
val_split: 0.1
max_length: 4096
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 8
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
grpo_beta: 0.1
num_generations: 4
reward_fn: accuracy
output: ./output

View File

@ -0,0 +1,37 @@
# Soup template: Full RLHF Pipeline (SFT + Reward Model + PPO)
# Three-stage training: 1) SFT warmup, 2) Reward model, 3) PPO alignment
#
# Usage:
# Step 1: soup train --config soup_sft.yaml # SFT warmup
# Step 2: soup train --config soup_rm.yaml # Train reward model
# Step 3: soup train --config soup_ppo.yaml # PPO with reward model
#
# This template generates the PPO config (step 3).
# For steps 1-2, use: soup init --template chat (SFT) and edit task to reward_model.
base: meta-llama/Llama-3.1-8B-Instruct
task: ppo
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/prompts.jsonl
format: chatml
val_split: 0.1
max_length: 2048
training:
epochs: 1
lr: 1e-6
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
reward_model: ./output_rm
ppo_epochs: 4
ppo_clip_ratio: 0.2
ppo_kl_penalty: 0.05
output: ./output_ppo

View File

@ -0,0 +1,30 @@
# Soup template: SimPO (Simple Preference Optimization)
# Reference-free preference alignment with length-normalized rewards
#
# Data format (JSONL):
# {"prompt": "What is 2+2?", "chosen": "4", "rejected": "Fish"}
base: meta-llama/Llama-3.1-8B-Instruct
task: simpo
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/preference_train.jsonl
format: dpo
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
simpo_gamma: 0.5
cpo_alpha: 1.0
output: ./output

View File

@ -0,0 +1,39 @@
# Soup template: Tool-Calling / Agentic Fine-tuning
# Fine-tune a model to call tools / functions correctly
#
# Data format (JSONL):
# {
# "messages": [{"role": "user", "content": "What's the weather in Tokyo?"}],
# "tools": [{"type": "function", "function": {
# "name": "get_weather",
# "description": "Get current weather for a city",
# "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
# }}],
# "tool_calls": [{"function": {
# "name": "get_weather",
# "arguments": "{\"city\": \"Tokyo\"}"
# }}]
# }
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/tool_calling_train.jsonl
format: tool-calling
val_split: 0.1
max_length: 4096
training:
epochs: 3
lr: 2e-4
batch_size: auto
gradient_accumulation_steps: 4
lora:
r: 16
alpha: 32
target_modules: auto
quantization: 4bit
output: ./output

View File

@ -0,0 +1,26 @@
# Soup template: Vision / Multimodal
# Fine-tune a vision-language model for image understanding
base: meta-llama/Llama-3.2-11B-Vision-Instruct
task: sft
modality: vision
# backend: unsloth # 2-5x faster, pip install 'soup-cli[fast]'
data:
train: ./data/vision_train.jsonl
format: llava
image_dir: ./data/images
val_split: 0.1
max_length: 2048
training:
epochs: 3
lr: 1e-5
batch_size: auto
lora:
r: 64
alpha: 16
target_modules: auto
quantization: 4bit
output: ./output

View File

@ -1,5 +1,6 @@
"""SFT (Supervised Fine-Tuning) trainer — wraps HuggingFace transformers + peft + trl."""
import logging
import time
from pathlib import Path
from typing import Optional
@ -9,6 +10,8 @@ from rich.console import Console
from soup_cli.config.schema import SoupConfig
from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
logger = logging.getLogger(__name__)
console = Console()
@ -470,7 +473,27 @@ class SFTTrainerWrapper:
use_dora=tcfg.lora.use_dora,
use_rslora=tcfg.lora.use_rslora,
)
# v0.39.0 Part D — surgical PEFT patches (Gemma4 ClippableLinear,
# MoE 3D expert dropout-strip). Pre-LoRA pass for ClippableLinear so
# PEFT's matcher sees the swapped nn.Linear; the model-name gate
# inside is_gemma4_model keeps the swap from running on non-Gemma4.
from soup_cli.utils.peft_patches import (
apply_gemma4_clippable_patch,
is_gemma4_model,
strip_lora_dropout_for_3d_experts,
)
if is_gemma4_model(cfg.base):
try:
apply_gemma4_clippable_patch(self.model)
except Exception as exc: # noqa: BLE001 — best-effort patch, log + continue
logger.debug("apply_gemma4_clippable_patch skipped: %s", exc)
self.model = get_peft_model(self.model, lora_config)
# Post-LoRA pass for 3-D expert dropout strip (architecture-detected
# via weight.ndim==3 inside the helper; safe to call unconditionally).
try:
strip_lora_dropout_for_3d_experts(self.model)
except Exception as exc: # noqa: BLE001 — best-effort patch, log + continue
logger.debug("strip_lora_dropout_for_3d_experts skipped: %s", exc)
self._apply_quantization_aware(tcfg)
@ -789,6 +812,18 @@ class SFTTrainerWrapper:
)
)
# ReLoRA callback (v0.39.0 Part B) — magnitude-prune LoRA weights every N steps
relora_steps = getattr(self.config.training, "relora_steps", None)
if relora_steps:
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
policy = ReLoRAPolicy(
steps=int(relora_steps),
warmup_ratio=float(self.config.training.relora_warmup_ratio),
reset_optimizer=bool(self.config.training.relora_reset_optimizer),
prune_ratio=float(self.config.training.relora_prune_ratio),
)
self.trainer.add_callback(ReLoRACallback(policy=policy))
# Activation offloading (v0.28.0) — wrap train() so saved-tensor hooks
# are active only during training (and removed afterwards).
from soup_cli.utils.activation_offload import offload_context

View File

@ -50,7 +50,18 @@ def build_peft_config(
"use_rslora": lora_cfg.use_rslora,
}
if lora_cfg.use_olora:
# v0.39.0 Part C — per-pattern rank/alpha (peft natively supports these)
if lora_cfg.rank_pattern:
init_kwargs["rank_pattern"] = dict(lora_cfg.rank_pattern)
if lora_cfg.alpha_pattern:
init_kwargs["alpha_pattern"] = dict(lora_cfg.alpha_pattern)
# init_strategy is the canonical source; use_olora is back-compat (validator
# aligns init_strategy='olora' when use_olora=True).
if lora_cfg.init_strategy in ("pissa", "olora"):
init_kwargs["init_lora_weights"] = lora_cfg.init_strategy
elif lora_cfg.use_olora:
# Defensive fallback if init_strategy alignment was bypassed.
init_kwargs["init_lora_weights"] = "olora"
return {

View File

@ -0,0 +1,151 @@
"""Surgical PEFT/architecture patches (v0.39.0 Part D).
Logging is via the standard ``logging`` module so failures are inspectable
without raising. Best-effort patches never crash training.
Two narrow patches that PEFT upstream doesn't (yet) handle:
1. **Gemma4 ``ClippableLinear``** Gemma 4 uses a ``ClippableLinear`` subclass
that PEFT's ``LoraConfig.target_modules`` matcher doesn't recognise. We
detect it by class name and swap to plain ``nn.Linear`` so PEFT's normal
matcher takes over. We don't try to preserve the clipping semantics
because Gemma4 only invokes them at inference; training is unaffected.
2. **Fused-MoE 3-D expert weights** ``ParamWrapper`` in PEFT crashes when a
LoRA layer wraps a 3-D weight tensor (``[num_experts, in, out]``). We
detect 3-D LoRA target modules and silently strip ``lora_dropout`` since
the dropout layer is what triggers the crash.
Both patches are version-gated and architecture-gated. Apply via the
public entry point :func:`apply_surgical_patches` which inspects the
model name and runs only the patches that match.
"""
from __future__ import annotations
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# Word-boundary match against ``model_name`` (case-insensitive). Bare substring
# would over-match on names like "ungemma4ed" or "my-gemma4ish". Boundaries:
# start/end-of-string or a non-alnum/underscore char on each side.
_GEMMA4_RE = re.compile(r"(?:^|[^a-z0-9])gemma-?4(?:[^a-z0-9]|$)", re.IGNORECASE)
def is_gemma4_model(model_name: Any) -> bool:
if not isinstance(model_name, str) or not model_name:
return False
if "\x00" in model_name:
return False
return _GEMMA4_RE.search(model_name) is not None
def _looks_like_clippable_linear(module: Any) -> bool:
"""Match by class name, not isinstance, so we don't import gemma internals."""
return type(module).__name__ == "ClippableLinear"
def apply_gemma4_clippable_patch(model: Any) -> int:
"""Swap any ``ClippableLinear`` submodules with plain ``nn.Linear``.
Returns the number of modules patched. Safe to call when no
``ClippableLinear`` is present (returns 0).
"""
import torch.nn as nn # lazy
if model is None:
return 0
swapped = 0
# Walk parents → swap by attribute. Avoid mutation during iteration:
# collect first, then patch.
targets: list[tuple[Any, str, Any]] = []
for parent in model.modules():
for child_name, child in list(parent.named_children()):
if _looks_like_clippable_linear(child):
targets.append((parent, child_name, child))
for parent, child_name, child in targets:
# Build a plain Linear with the same shape + dtype + device.
in_features = getattr(child, "in_features", None)
out_features = getattr(child, "out_features", None)
bias = getattr(child, "bias", None) is not None
if in_features is None or out_features is None:
continue
replacement = nn.Linear(in_features, out_features, bias=bias)
# Copy weights over (best-effort).
try:
replacement.weight.data.copy_(child.weight.data)
if bias and replacement.bias is not None and child.bias is not None:
replacement.bias.data.copy_(child.bias.data)
replacement = replacement.to(
dtype=child.weight.dtype, device=child.weight.device
)
except Exception as exc: # noqa: BLE001 — fall back to random init w/ log
logger.debug(
"Failed to copy ClippableLinear weights at %s: %s; using fresh init",
child_name, exc,
)
setattr(parent, child_name, replacement)
swapped += 1
return swapped
def strip_lora_dropout_for_3d_experts(peft_model: Any) -> int:
"""Zero out ``lora_dropout`` on any LoRA target whose base weight is 3-D.
PEFT's ``ParamWrapper`` for fused-MoE experts cannot wrap an
``nn.Dropout`` instance whose forward expects a 2-D tensor when the
expert weight is shaped ``[num_experts, in, out]``. Setting ``p=0.0``
is the documented workaround upstream (Axolotl ``adapter.py``).
Returns the number of modules whose dropout was disabled.
"""
if peft_model is None:
return 0
count = 0
for _name, module in peft_model.named_modules():
weight = getattr(module, "weight", None)
if weight is None:
continue
ndim = getattr(weight, "ndim", None)
if ndim != 3:
continue
dropout = getattr(module, "lora_dropout", None)
if dropout is None:
continue
# dropout may be an nn.Dropout, an nn.ModuleDict (PEFT >=0.10), or a
# plain attribute with a ``p`` field. Cover the common shapes.
try:
if hasattr(dropout, "p"):
dropout.p = 0.0
count += 1
elif hasattr(dropout, "values"):
for sub in dropout.values():
if hasattr(sub, "p"):
sub.p = 0.0
count += 1
except Exception:
continue
return count
def apply_surgical_patches(model: Any, model_name: str) -> dict[str, int]:
"""Run all gated PEFT/architecture patches for ``model``.
Returns a dict ``{"gemma4_clippable": int, "moe_3d_dropout": int}``
counts of modules patched.
"""
if not isinstance(model_name, str) or not model_name:
raise ValueError("model_name must be a non-empty string")
if "\x00" in model_name:
raise ValueError("model_name cannot contain null bytes")
counts = {"gemma4_clippable": 0, "moe_3d_dropout": 0}
if is_gemma4_model(model_name):
counts["gemma4_clippable"] = apply_gemma4_clippable_patch(model)
counts["moe_3d_dropout"] = strip_lora_dropout_for_3d_experts(model)
return counts

177
soup_cli/utils/relora.py Normal file
View File

@ -0,0 +1,177 @@
"""ReLoRA — periodic LoRA adapter magnitude-prune + optimizer reset.
Mirrors the technique described in the ReLoRA paper / Axolotl
``monkeypatch/relora.py``: every N steps, magnitude-prune the LoRA
adapter weights and reset the optimizer state. Useful for very long
training runs where the LoRA capacity saturates.
The callback is a no-op when ``policy`` is ``None``. Pass a
:class:`ReLoRAPolicy` to enable it. Lazy imports keep the module CLI-fast.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
# Cap relora_steps to a sane upper bound.
MAX_RELORA_STEPS = 10**7
@dataclass(frozen=True)
class ReLoRAPolicy:
"""Frozen policy for the ReLoRA callback.
Attributes:
steps: fire every N global steps (must be > 0).
warmup_ratio: fraction of total steps to skip at the start ([0, 1]).
reset_optimizer: if True, clears optimizer state for the pruned
LoRA parameters after pruning so momentum doesn't fight the
new sparse weights.
prune_ratio: fraction of LoRA weights to zero out, by magnitude
(0 < x <= 1; e.g. 0.9 keeps the top 10%).
"""
steps: int
warmup_ratio: float = 0.1
reset_optimizer: bool = True
prune_ratio: float = 0.9
def __post_init__(self) -> None:
if not isinstance(self.steps, int) or isinstance(self.steps, bool):
raise ValueError("ReLoRAPolicy.steps must be int")
if self.steps <= 0 or self.steps > MAX_RELORA_STEPS:
raise ValueError(
f"ReLoRAPolicy.steps must be in (0, {MAX_RELORA_STEPS}], got {self.steps}"
)
if not (0.0 <= self.warmup_ratio <= 1.0):
raise ValueError(
f"ReLoRAPolicy.warmup_ratio must be in [0, 1], got {self.warmup_ratio}"
)
# Mirror magnitude_prune_tensor's strict (0, 1) bound. prune_ratio=1.0
# would zero every weight on first fire — that's a footgun, not a feature.
if not (0.0 < self.prune_ratio < 1.0):
raise ValueError(
f"ReLoRAPolicy.prune_ratio must be in (0, 1), got {self.prune_ratio}"
)
def should_fire(self, global_step: int, total_steps: Optional[int] = None) -> bool:
if global_step <= 0:
return False
if global_step % self.steps != 0:
return False
if total_steps is not None and total_steps > 0:
warmup_cutoff = int(total_steps * self.warmup_ratio)
if global_step < warmup_cutoff:
return False
return True
def magnitude_prune_tensor(tensor: Any, prune_ratio: float) -> Any:
"""Zero out the smallest-magnitude entries of ``tensor`` in place.
``prune_ratio=0.9`` keeps the top 10% of weights by absolute value.
``prune_ratio`` of exactly 0.0 or 1.0 is rejected to avoid silent
no-ops or wholesale zeroing.
"""
if not (0.0 < prune_ratio < 1.0):
raise ValueError(
f"magnitude_prune_tensor prune_ratio must be in (0, 1), got {prune_ratio}"
)
import torch # lazy
if not isinstance(tensor, torch.Tensor):
raise TypeError(f"magnitude_prune_tensor expects torch.Tensor, got {type(tensor)}")
flat = tensor.detach().abs().reshape(-1)
# Empty / 1-element tensor: nothing meaningful to prune.
if flat.numel() <= 1:
return tensor
k = max(1, int(flat.numel() * prune_ratio))
if k >= flat.numel():
# keep the single largest
k = flat.numel() - 1
# The k-th smallest absolute value: anything <= it gets zeroed
threshold = torch.kthvalue(flat, k).values
mask = tensor.detach().abs() > threshold
tensor.detach().mul_(mask.to(tensor.dtype))
return tensor
def _is_lora_param_name(name: str) -> bool:
"""Match PEFT's lora_A / lora_B parameter naming."""
return ("lora_A" in name) or ("lora_B" in name)
class ReLoRACallback:
"""HF TrainerCallback that magnitude-prunes LoRA weights every N steps.
We don't subclass ``transformers.TrainerCallback`` here so importing
this module never loads transformers. The Trainer's callback dispatch
works structurally any object with the right method names is fine.
"""
def __init__(self, policy: Optional[ReLoRAPolicy], console: Any = None) -> None:
self.policy = policy
self.console = console
self.fire_count = 0
def on_step_end(
self,
args: Any,
state: Any,
control: Any,
**kwargs: Any,
) -> Any:
if self.policy is None:
return control
global_step = int(getattr(state, "global_step", 0) or 0)
total_steps = getattr(state, "max_steps", None)
try:
total_steps_int = int(total_steps) if total_steps else None
except (TypeError, ValueError):
total_steps_int = None
if not self.policy.should_fire(global_step, total_steps_int):
return control
model = kwargs.get("model")
optimizer = kwargs.get("optimizer")
if model is None:
return control
self._prune_and_reset(model, optimizer)
self.fire_count += 1
if self.console is not None:
try:
self.console.print(
f"[yellow]ReLoRA[/yellow] fired at step {global_step} "
f"(prune_ratio={self.policy.prune_ratio})"
)
except Exception: # noqa: BLE001 — console.print is best-effort in callback
pass
return control
def _prune_and_reset(self, model: Any, optimizer: Any) -> None:
import torch # lazy
pruned_params = []
for name, param in model.named_parameters():
if not _is_lora_param_name(name):
continue
if param.requires_grad and param.numel() > 0:
with torch.no_grad():
magnitude_prune_tensor(param.data, self.policy.prune_ratio)
pruned_params.append(param)
if optimizer is None or not self.policy.reset_optimizer:
return
# Reset optimizer state only for the pruned parameters.
try:
state = getattr(optimizer, "state", None)
if state is None:
return
for param in pruned_params:
if param in state:
state[param] = type(state[param])() if state[param] else {}
except Exception:
# Optimizer state structure varies (DeepSpeed / FSDP wrap it);
# silent best-effort is the documented Axolotl behaviour too.
return

197
tests/test_peft_patches.py Normal file
View File

@ -0,0 +1,197 @@
"""Tests for v0.39.0 Part D — surgical PEFT patches.
Covers detection helpers + gated patch entry points for:
- Gemma4 ``ClippableLinear`` (PEFT's LoRA layer registry doesn't know about it)
- Fused-MoE 3-D expert weights (PEFT's ParamWrapper crashes on dropout)
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
# --- Gemma4 ClippableLinear detection ---------------------------------------
class TestGemma4Detection:
def test_is_gemma4_positive_lower(self):
from soup_cli.utils.peft_patches import is_gemma4_model
assert is_gemma4_model("google/gemma-4-9b") is True
assert is_gemma4_model("google/gemma-4-it") is True
assert is_gemma4_model("Gemma-4-2B") is True
def test_is_gemma4_negative(self):
from soup_cli.utils.peft_patches import is_gemma4_model
assert is_gemma4_model("google/gemma-2-9b") is False
assert is_gemma4_model("meta-llama/Meta-Llama-3.1-8B") is False
assert is_gemma4_model("") is False
assert is_gemma4_model(None) is False # type: ignore
def test_is_gemma4_word_boundary(self):
"""v0.39.0 security fix — substring match would over-match."""
from soup_cli.utils.peft_patches import is_gemma4_model
# NOT Gemma 4
assert is_gemma4_model("ungemma4ed") is False
assert is_gemma4_model("megagemma40-experiment") is False
# IS Gemma 4 — word-boundary cases
assert is_gemma4_model("my-org/finetuned-gemma-4-style") is True
assert is_gemma4_model("google/gemma4_instruct") is True
def test_is_gemma4_rejects_null_byte(self):
from soup_cli.utils.peft_patches import is_gemma4_model
# crafted name with null byte should not match
assert is_gemma4_model("gemma-4\x00malicious") is False
class TestClippableLinearPatch:
def test_no_clippable_linear_returns_zero(self):
try:
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.peft_patches import apply_gemma4_clippable_patch
class Model(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Linear(4, 4)
model = Model()
count = apply_gemma4_clippable_patch(model)
assert count == 0
def test_clippable_linear_replaced(self):
try:
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.peft_patches import apply_gemma4_clippable_patch
# Simulate Gemma4's ClippableLinear by name
class ClippableLinear(nn.Linear):
pass
class Model(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = ClippableLinear(4, 4)
self.fc2 = nn.Linear(4, 4)
model = Model()
count = apply_gemma4_clippable_patch(model)
assert count == 1
# After patch: fc1 is plain nn.Linear (or PEFT-recognised)
assert type(model.fc1) is nn.Linear
# --- MoE 3D expert dropout strip --------------------------------------------
class TestMoE3DDropoutStrip:
def test_strip_when_no_3d_experts(self):
from soup_cli.utils.peft_patches import strip_lora_dropout_for_3d_experts
# peft model with only 2-D weights — strip is no-op
peft_model = MagicMock()
peft_model.named_modules.return_value = [
("base.layer1", MagicMock(weight=MagicMock(ndim=2))),
]
count = strip_lora_dropout_for_3d_experts(peft_model)
assert count == 0
def test_strip_zeroes_dropout_on_3d_module(self):
from soup_cli.utils.peft_patches import strip_lora_dropout_for_3d_experts
# Build a fake module tree: experts.0.gate_proj has 3-D weight + lora_dropout
expert = MagicMock()
expert.weight = MagicMock(ndim=3)
expert.lora_dropout = MagicMock()
expert.lora_dropout.p = 0.1
peft_model = MagicMock()
peft_model.named_modules.return_value = [
("base.experts.0.gate_proj", expert),
]
count = strip_lora_dropout_for_3d_experts(peft_model)
assert count == 1
assert expert.lora_dropout.p == 0.0
def test_strip_handles_module_dict_dropout(self):
"""PEFT >=0.10 wraps lora_dropout in a ModuleDict — exercise the values() branch."""
from soup_cli.utils.peft_patches import strip_lora_dropout_for_3d_experts
sub_a = MagicMock(spec=["p"])
sub_a.p = 0.1
sub_b = MagicMock(spec=["p"])
sub_b.p = 0.2
class FakeModuleDict:
def values(self):
return [sub_a, sub_b]
expert = MagicMock(spec=["weight", "lora_dropout"])
expert.weight = MagicMock(ndim=3)
# FakeModuleDict has no `p` attribute → hasattr() is False → elif fires.
expert.lora_dropout = FakeModuleDict()
peft_model = MagicMock()
peft_model.named_modules.return_value = [("base.experts.0", expert)]
count = strip_lora_dropout_for_3d_experts(peft_model)
assert count == 2
assert sub_a.p == 0.0
assert sub_b.p == 0.0
class TestApplySurgicalPatches:
def test_returns_dict_with_counts(self):
try:
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.peft_patches import apply_surgical_patches
class Model(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Linear(4, 4)
result = apply_surgical_patches(Model(), model_name="meta-llama/Meta-Llama-3.1-8B")
assert isinstance(result, dict)
assert "gemma4_clippable" in result
assert "moe_3d_dropout" in result
assert result["gemma4_clippable"] == 0
def test_gemma4_only_runs_for_gemma4_models(self):
try:
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.peft_patches import apply_surgical_patches
class ClippableLinear(nn.Linear):
pass
class Model(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = ClippableLinear(4, 4)
# non-Gemma4: skip
m = Model()
result = apply_surgical_patches(m, model_name="meta-llama/Llama-3-8B")
assert result["gemma4_clippable"] == 0
assert type(m.fc1) is ClippableLinear
# Gemma4: apply
m2 = Model()
result2 = apply_surgical_patches(m2, model_name="google/gemma-4-9b")
assert result2["gemma4_clippable"] == 1
def test_rejects_empty_model_name(self):
from soup_cli.utils.peft_patches import apply_surgical_patches
with pytest.raises(ValueError):
apply_surgical_patches(MagicMock(), model_name="")
def test_rejects_null_byte_model_name(self):
from soup_cli.utils.peft_patches import apply_surgical_patches
with pytest.raises(ValueError):
apply_surgical_patches(MagicMock(), model_name="gemma-4\x00x")

133
tests/test_pissa_init.py Normal file
View File

@ -0,0 +1,133 @@
"""Tests for v0.39.0 Part A — PiSSA init + init_strategy field on LoraConfig."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from soup_cli.config.schema import LoraConfig
from soup_cli.utils.peft_builder import build_peft_config
class TestInitStrategyField:
def test_default_is_random(self):
cfg = LoraConfig()
assert cfg.init_strategy == "random"
def test_accepts_pissa(self):
cfg = LoraConfig(init_strategy="pissa")
assert cfg.init_strategy == "pissa"
def test_accepts_olora(self):
cfg = LoraConfig(init_strategy="olora")
assert cfg.init_strategy == "olora"
def test_rejects_unknown(self):
with pytest.raises(ValidationError):
LoraConfig(init_strategy="loftq")
def test_rejects_non_string(self):
with pytest.raises(ValidationError):
LoraConfig(init_strategy=42)
class TestBackcompatNoMutation:
def test_backcompat_does_not_mutate_input_dict(self):
"""v0.39.0 security fix — _backcompat_align_olora must copy."""
original = {"use_olora": True}
LoraConfig(**original)
assert "init_strategy" not in original, (
"Validator mutated caller's dict in-place"
)
class TestInitStrategyOloraBackcompat:
def test_use_olora_true_alone_still_works(self):
cfg = LoraConfig(use_olora=True)
assert cfg.use_olora is True
assert cfg.init_strategy == "olora" # auto-aligned for back-compat
def test_init_strategy_olora_alone_works(self):
cfg = LoraConfig(init_strategy="olora")
assert cfg.init_strategy == "olora"
# use_olora may be True or False; effect is via init_strategy
def test_both_set_consistent_ok(self):
cfg = LoraConfig(use_olora=True, init_strategy="olora")
assert cfg.init_strategy == "olora"
def test_use_olora_true_with_pissa_rejected(self):
with pytest.raises(ValidationError, match="init_strategy"):
LoraConfig(use_olora=True, init_strategy="pissa")
def test_use_olora_true_with_random_rejected(self):
# explicit conflict — user said both random and olora; loud-fail
with pytest.raises(ValidationError, match="init_strategy"):
LoraConfig(use_olora=True, init_strategy="random")
class TestInitStrategyMutualExclusion:
def test_pissa_with_dora_rejected(self):
# PiSSA + DoRA isn't supported (PEFT init_lora_weights conflicts with use_dora init)
with pytest.raises(ValidationError, match="init_strategy"):
LoraConfig(use_dora=True, init_strategy="pissa")
def test_pissa_with_vera_rejected(self):
# VeRA doesn't have init_lora_weights — PiSSA meaningless
with pytest.raises(ValidationError, match="init_strategy"):
LoraConfig(use_vera=True, init_strategy="pissa")
def test_pissa_with_rslora_ok(self):
# rsLoRA only changes scaling factor — orthogonal to init
cfg = LoraConfig(use_rslora=True, init_strategy="pissa")
assert cfg.init_strategy == "pissa"
assert cfg.use_rslora is True
class TestPeftBuilderInitStrategy:
def test_random_does_not_set_init_lora_weights(self):
cfg = LoraConfig(init_strategy="random")
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["peft_cls"] == "LoraConfig"
assert "init_lora_weights" not in spec["init_kwargs"]
def test_pissa_sets_init_lora_weights(self):
cfg = LoraConfig(init_strategy="pissa")
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["init_kwargs"]["init_lora_weights"] == "pissa"
def test_olora_via_init_strategy(self):
cfg = LoraConfig(init_strategy="olora")
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["init_kwargs"]["init_lora_weights"] == "olora"
def test_olora_via_use_olora_legacy(self):
cfg = LoraConfig(use_olora=True)
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["init_kwargs"]["init_lora_weights"] == "olora"
def test_vera_ignores_init_strategy_random(self):
cfg = LoraConfig(use_vera=True, init_strategy="random")
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["peft_cls"] == "VeraConfig"
assert "init_lora_weights" not in spec["init_kwargs"]
class TestInstantiatePeftConfig:
def test_instantiate_lora_config(self):
from soup_cli.utils.peft_builder import instantiate_peft_config
cfg = LoraConfig(init_strategy="pissa")
spec = build_peft_config(cfg, target_modules=["q_proj", "v_proj"], task_type="CAUSAL_LM")
result = instantiate_peft_config(spec)
# Verify it really is a peft.LoraConfig with the right kwargs
import peft
assert isinstance(result, peft.LoraConfig)
assert result.r == 64
assert result.init_lora_weights == "pissa"
def test_instantiate_with_rank_pattern(self):
from soup_cli.utils.peft_builder import instantiate_peft_config
cfg = LoraConfig(rank_pattern={"q_proj": 8})
spec = build_peft_config(cfg, target_modules=["q_proj"], task_type="CAUSAL_LM")
result = instantiate_peft_config(spec)
assert result.rank_pattern == {"q_proj": 8}

View File

@ -0,0 +1,98 @@
"""Tests for v0.39.0 Part C — per-pattern LoRA rank/alpha."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from soup_cli.config.schema import LoraConfig
from soup_cli.utils.peft_builder import build_peft_config
class TestRankPatternSchema:
def test_default_none(self):
cfg = LoraConfig()
assert cfg.rank_pattern is None
assert cfg.alpha_pattern is None
def test_rank_pattern_dict_accepted(self):
cfg = LoraConfig(rank_pattern={"q_proj": 8, "v_proj": 16})
assert cfg.rank_pattern == {"q_proj": 8, "v_proj": 16}
def test_alpha_pattern_dict_accepted(self):
cfg = LoraConfig(alpha_pattern={"q_proj": 16, "v_proj": 32})
assert cfg.alpha_pattern == {"q_proj": 16, "v_proj": 32}
def test_rank_pattern_rejects_non_int_value(self):
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"q_proj": "high"})
def test_rank_pattern_rejects_negative(self):
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"q_proj": -1})
def test_rank_pattern_rejects_zero(self):
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"q_proj": 0})
def test_rank_pattern_rejects_too_large(self):
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"q_proj": 10_000})
def test_rank_pattern_rejects_empty_key(self):
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"": 8})
def test_rank_pattern_rejects_null_byte_key(self):
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"q\x00proj": 8})
def test_rank_pattern_rejects_too_many_keys(self):
# Cap at 256 patterns to prevent absurd configs
big = {f"k{i}": 8 for i in range(257)}
with pytest.raises(ValidationError):
LoraConfig(rank_pattern=big)
def test_rank_pattern_rejects_bool_value(self):
# bool is subclass of int in Python — exclude explicitly
with pytest.raises(ValidationError):
LoraConfig(rank_pattern={"q_proj": True})
class TestRankPatternMutualExclusion:
def test_rank_pattern_with_vera_rejected(self):
with pytest.raises(ValidationError, match="rank_pattern"):
LoraConfig(use_vera=True, rank_pattern={"q_proj": 8})
def test_alpha_pattern_with_vera_rejected(self):
with pytest.raises(ValidationError, match="alpha_pattern"):
LoraConfig(use_vera=True, alpha_pattern={"q_proj": 16})
def test_rank_pattern_with_dora_ok(self):
# DoRA still uses standard LoraConfig; rank_pattern works
cfg = LoraConfig(use_dora=True, rank_pattern={"q_proj": 8})
assert cfg.rank_pattern == {"q_proj": 8}
class TestPeftBuilderRankPattern:
def test_rank_pattern_propagated(self):
cfg = LoraConfig(rank_pattern={"q_proj": 8, "v_proj": 16})
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["init_kwargs"]["rank_pattern"] == {"q_proj": 8, "v_proj": 16}
def test_alpha_pattern_propagated(self):
cfg = LoraConfig(alpha_pattern={"q_proj": 16})
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["init_kwargs"]["alpha_pattern"] == {"q_proj": 16}
def test_neither_pattern_omitted_when_none(self):
cfg = LoraConfig()
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert "rank_pattern" not in spec["init_kwargs"]
assert "alpha_pattern" not in spec["init_kwargs"]
def test_vera_path_ignores_patterns_when_unset(self):
cfg = LoraConfig(use_vera=True)
spec = build_peft_config(cfg, target_modules="auto", task_type="CAUSAL_LM")
assert spec["peft_cls"] == "VeraConfig"
assert "rank_pattern" not in spec["init_kwargs"]

359
tests/test_relora.py Normal file
View File

@ -0,0 +1,359 @@
"""Tests for v0.39.0 Part B — ReLoRA callback."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from pydantic import ValidationError
from soup_cli.config.schema import TrainingConfig
class TestReLoRASchema:
def test_default_disabled(self):
cfg = TrainingConfig()
assert cfg.relora_steps is None
assert cfg.relora_warmup_ratio == 0.1
assert cfg.relora_reset_optimizer is True
assert 0.0 < cfg.relora_prune_ratio <= 1.0
def test_relora_steps_positive(self):
cfg = TrainingConfig(relora_steps=500)
assert cfg.relora_steps == 500
def test_relora_steps_rejects_zero(self):
with pytest.raises(ValidationError):
TrainingConfig(relora_steps=0)
def test_relora_steps_rejects_negative(self):
with pytest.raises(ValidationError):
TrainingConfig(relora_steps=-1)
def test_relora_steps_upper_bound(self):
# Cap at 10**7 to prevent overflow / nonsensical values
with pytest.raises(ValidationError):
TrainingConfig(relora_steps=10**8)
def test_relora_warmup_ratio_bounds(self):
TrainingConfig(relora_warmup_ratio=0.0)
TrainingConfig(relora_warmup_ratio=1.0)
with pytest.raises(ValidationError):
TrainingConfig(relora_warmup_ratio=-0.01)
with pytest.raises(ValidationError):
TrainingConfig(relora_warmup_ratio=1.01)
def test_relora_prune_ratio_bounds(self):
TrainingConfig(relora_prune_ratio=0.5)
TrainingConfig(relora_prune_ratio=0.99)
with pytest.raises(ValidationError):
TrainingConfig(relora_prune_ratio=0.0)
with pytest.raises(ValidationError):
TrainingConfig(relora_prune_ratio=1.0)
with pytest.raises(ValidationError):
TrainingConfig(relora_prune_ratio=1.01)
class TestReLoRAPolicy:
def test_policy_frozen(self):
from dataclasses import FrozenInstanceError
from soup_cli.utils.relora import ReLoRAPolicy
p = ReLoRAPolicy(steps=500, warmup_ratio=0.1, reset_optimizer=True, prune_ratio=0.9)
with pytest.raises(FrozenInstanceError):
p.steps = 999 # type: ignore
def test_policy_should_fire_step_zero_no(self):
from soup_cli.utils.relora import ReLoRAPolicy
p = ReLoRAPolicy(steps=500)
assert p.should_fire(global_step=0) is False
def test_policy_should_fire_at_multiple(self):
from soup_cli.utils.relora import ReLoRAPolicy
p = ReLoRAPolicy(steps=500)
assert p.should_fire(global_step=500) is True
assert p.should_fire(global_step=1000) is True
def test_policy_should_fire_skips_warmup(self):
from soup_cli.utils.relora import ReLoRAPolicy
# warmup_ratio=0.5 over total 1000 = first 500 skipped
p = ReLoRAPolicy(steps=200, warmup_ratio=0.5)
assert p.should_fire(global_step=200, total_steps=1000) is False
assert p.should_fire(global_step=600, total_steps=1000) is True
def test_policy_rejects_invalid_steps(self):
from soup_cli.utils.relora import ReLoRAPolicy
with pytest.raises(ValueError):
ReLoRAPolicy(steps=0)
with pytest.raises(ValueError):
ReLoRAPolicy(steps=-1)
def test_policy_rejects_invalid_prune_ratio(self):
from soup_cli.utils.relora import ReLoRAPolicy
with pytest.raises(ValueError):
ReLoRAPolicy(steps=500, prune_ratio=0.0)
with pytest.raises(ValueError):
ReLoRAPolicy(steps=500, prune_ratio=1.0)
with pytest.raises(ValueError):
ReLoRAPolicy(steps=500, prune_ratio=1.5)
class TestMagnitudePrune:
def test_magnitude_prune_zeroes_low_magnitude(self):
try:
import torch
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import magnitude_prune_tensor
x = torch.tensor([0.01, 0.02, 0.5, 1.0, 2.0])
# prune_ratio=0.6 → keep top 40% (2 of 5) → smallest 3 zeroed
out = magnitude_prune_tensor(x.clone(), prune_ratio=0.6)
nonzero = (out != 0).sum().item()
assert nonzero == 2
# the two largest must survive
assert (out.abs() == 2.0).any()
assert (out.abs() == 1.0).any()
def test_magnitude_prune_single_element_no_crash(self):
try:
import torch
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import magnitude_prune_tensor
# 1-element tensor — kthvalue(_, 0) would raise; helper must short-circuit.
x = torch.tensor([3.14])
out = magnitude_prune_tensor(x.clone(), prune_ratio=0.5)
# untouched (use approx for float32 storage)
assert out.item() == pytest.approx(3.14, abs=1e-5)
def test_magnitude_prune_keep_all(self):
try:
import torch
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import magnitude_prune_tensor
x = torch.tensor([1.0, 2.0, 3.0])
out = magnitude_prune_tensor(x.clone(), prune_ratio=0.001)
# near-zero prune ratio → at least one element kept
assert (out != 0).any()
def test_magnitude_prune_rejects_invalid_ratio(self):
try:
import torch
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import magnitude_prune_tensor
x = torch.zeros(3)
with pytest.raises(ValueError):
magnitude_prune_tensor(x, prune_ratio=0.0)
with pytest.raises(ValueError):
magnitude_prune_tensor(x, prune_ratio=1.0)
def test_magnitude_prune_rejects_non_tensor_input(self):
try:
import torch # noqa: F401
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import magnitude_prune_tensor
with pytest.raises(TypeError):
magnitude_prune_tensor([1.0, 2.0, 3.0], prune_ratio=0.5)
with pytest.raises(TypeError):
magnitude_prune_tensor("not a tensor", prune_ratio=0.5)
class TestReLoRACallback:
def test_callback_disabled_no_op(self):
from soup_cli.utils.relora import ReLoRACallback
cb = ReLoRACallback(policy=None)
# disabled callback never fires
state = MagicMock(global_step=500, max_steps=1000)
ctrl = MagicMock()
args = MagicMock()
cb.on_step_end(args, state, ctrl)
assert cb.fire_count == 0
def test_callback_fires_on_relora_step(self):
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
cb = ReLoRACallback(policy=ReLoRAPolicy(steps=100))
# mock model with PEFT-style lora_A / lora_B parameters
cb._prune_and_reset = MagicMock() # type: ignore[method-assign]
state = MagicMock(global_step=100, max_steps=1000)
ctrl = MagicMock()
args = MagicMock()
cb.on_step_end(args, state, ctrl, model=MagicMock(), optimizer=MagicMock())
assert cb.fire_count == 1
cb._prune_and_reset.assert_called_once()
def test_callback_does_not_fire_off_step(self):
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
cb = ReLoRACallback(policy=ReLoRAPolicy(steps=100))
cb._prune_and_reset = MagicMock() # type: ignore[method-assign]
state = MagicMock(global_step=99, max_steps=1000)
cb.on_step_end(MagicMock(), state, MagicMock())
assert cb.fire_count == 0
cb._prune_and_reset.assert_not_called()
def test_callback_skips_warmup(self):
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
cb = ReLoRACallback(policy=ReLoRAPolicy(steps=100, warmup_ratio=0.5))
cb._prune_and_reset = MagicMock() # type: ignore[method-assign]
# at step 100 with total 1000 → warmup is 500 → skip
state = MagicMock(global_step=100, max_steps=1000)
cb.on_step_end(MagicMock(), state, MagicMock(), model=MagicMock(), optimizer=MagicMock())
assert cb.fire_count == 0
class TestReLoRATaskGate:
def _base_cfg(self, task: str = "sft", backend: str = "transformers") -> dict:
return {
"base": "meta-llama/Llama-3.1-8B",
"task": task,
"backend": backend,
"data": {"train": "./data.jsonl"},
"training": {"relora_steps": 100},
}
def test_sft_accepted(self):
import yaml
from soup_cli.config.loader import load_config_from_string
cfg = load_config_from_string(yaml.safe_dump(self._base_cfg("sft")))
assert cfg.training.relora_steps == 100
@pytest.mark.parametrize(
"task",
["dpo", "grpo", "kto", "orpo", "simpo", "ipo",
"ppo", "reward_model", "pretrain", "embedding"],
)
def test_other_tasks_rejected(self, task):
import yaml
from soup_cli.config.loader import load_config_from_string
# load_config_from_string surfaces a ValueError (Pydantic validation
# error) that wraps the cross-validator's message.
with pytest.raises(ValueError, match="relora_steps"):
load_config_from_string(yaml.safe_dump(self._base_cfg(task)))
def test_mlx_backend_rejected(self):
import yaml
from soup_cli.config.loader import load_config_from_string
with pytest.raises(ValueError, match="mlx"):
load_config_from_string(yaml.safe_dump(self._base_cfg("sft", "mlx")))
def test_no_relora_no_gate(self):
# multi-task without relora_steps stays valid
import yaml
from soup_cli.config.loader import load_config_from_string
d = self._base_cfg("dpo")
d["training"].pop("relora_steps")
cfg = load_config_from_string(yaml.safe_dump(d))
assert cfg.training.relora_steps is None
class TestPruneAndReset:
def test_prune_and_reset_targets_lora_modules(self):
try:
import torch
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
class FakeLoraModule(nn.Module):
def __init__(self):
super().__init__()
self.lora_A = nn.Linear(4, 2, bias=False)
self.lora_B = nn.Linear(2, 4, bias=False)
self.base = nn.Linear(4, 4, bias=False)
def forward(self, x):
return self.base(x) + self.lora_B(self.lora_A(x))
model = FakeLoraModule()
# set known weight values
with torch.no_grad():
model.lora_A.weight.fill_(1.0)
model.lora_A.weight[0, 0] = 100.0
model.lora_B.weight.fill_(0.5)
model.base.weight.fill_(7.0)
before_base = model.base.weight.clone()
cb = ReLoRACallback(policy=ReLoRAPolicy(steps=10, prune_ratio=0.9))
opt = MagicMock()
opt.state = {}
cb._prune_and_reset(model, opt)
# base must be untouched
assert torch.equal(model.base.weight, before_base)
# lora_A retains highest-magnitude entry
assert (model.lora_A.weight.abs() >= 100.0).any()
# lora_A overall has many zeros now (prune_ratio=0.9)
zero_frac = (model.lora_A.weight == 0).float().mean().item()
assert zero_frac > 0.5
def test_prune_and_reset_clears_real_optimizer_state(self):
try:
import torch
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
class FakeLoraModule(nn.Module):
def __init__(self):
super().__init__()
self.lora_A = nn.Linear(4, 2, bias=False)
def forward(self, x):
return self.lora_A(x)
model = FakeLoraModule()
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
# Run a step so opt.state is populated for the param.
loss = model(torch.randn(2, 4)).sum()
loss.backward()
opt.step()
param = model.lora_A.weight
assert param in opt.state
assert len(opt.state[param]) > 0 # exp_avg, exp_avg_sq, step
cb = ReLoRACallback(policy=ReLoRAPolicy(steps=10, prune_ratio=0.9))
cb._prune_and_reset(model, opt)
# Optimizer state for the pruned param must have been reset to empty
assert len(opt.state[param]) == 0
def test_prune_and_reset_respects_reset_optimizer_false(self):
try:
import torch
import torch.nn as nn
except ImportError:
pytest.skip("torch not available")
from soup_cli.utils.relora import ReLoRACallback, ReLoRAPolicy
class FakeLoraModule(nn.Module):
def __init__(self):
super().__init__()
self.lora_A = nn.Linear(4, 2, bias=False)
def forward(self, x):
return self.lora_A(x)
model = FakeLoraModule()
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
loss = model(torch.randn(2, 4)).sum()
loss.backward()
opt.step()
param = model.lora_A.weight
before = len(opt.state[param])
cb = ReLoRACallback(
policy=ReLoRAPolicy(steps=10, prune_ratio=0.9, reset_optimizer=False)
)
cb._prune_and_reset(model, opt)
# State preserved when reset_optimizer=False
assert len(opt.state[param]) == before

View File

@ -0,0 +1,140 @@
"""Tests for v0.39.0 Part E — template registry YAML migration."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
class TestTemplateRegistry:
def test_list_templates_includes_core_set(self):
from soup_cli.templates import list_templates
names = list_templates()
for required in (
"chat", "code", "reasoning", "vision", "kto", "orpo",
"simpo", "ipo", "pretrain", "moe", "longcontext", "embedding",
"audio", "tool-calling", "rlhf", "medical",
):
assert required in names, f"{required} missing from {names}"
def test_list_templates_returns_sorted(self):
from soup_cli.templates import list_templates
names = list_templates()
assert names == sorted(names)
def test_load_template_chat_from_yaml(self):
from soup_cli.templates import load_template
body = load_template("chat")
assert body is not None
assert "Soup template: Chat Assistant" in body
assert "task: sft" in body
def test_load_template_unknown_returns_none(self):
from soup_cli.templates import load_template
assert load_template("does-not-exist") is None
def test_load_template_rejects_path_traversal(self):
from soup_cli.templates import load_template
with pytest.raises(ValueError):
load_template("../../etc/passwd")
with pytest.raises(ValueError):
load_template("foo/bar")
with pytest.raises(ValueError):
load_template("foo\\bar")
def test_load_template_rejects_null_byte(self):
from soup_cli.templates import load_template
with pytest.raises(ValueError):
load_template("chat\x00malicious")
def test_load_template_rejects_empty_name(self):
from soup_cli.templates import load_template
with pytest.raises(ValueError):
load_template("")
def test_yaml_files_exist_for_all_inline(self):
from soup_cli.config.schema import TEMPLATES
templates_dir = Path(__file__).resolve().parent.parent / "soup_cli" / "templates"
for name in TEMPLATES:
yaml_path = templates_dir / f"{name}.yaml"
assert yaml_path.is_file(), f"Missing YAML for inline template {name}"
def test_manifest_well_formed(self):
templates_dir = Path(__file__).resolve().parent.parent / "soup_cli" / "templates"
manifest_path = templates_dir / "manifest.json"
assert manifest_path.is_file()
with manifest_path.open() as f:
data = json.load(f)
assert "templates" in data
assert "version" in data
assert isinstance(data["templates"], dict)
def test_yaml_content_matches_inline_for_all_templates(self):
"""All 16 inline templates must match their YAML siblings exactly.
v0.39.0 Part E ships both sources for back-compat; drift between
them is a contributor footgun. This guards against silent edits
of one source without the other.
"""
from soup_cli.config.schema import TEMPLATES
from soup_cli.templates import load_template
for name in TEMPLATES:
assert load_template(name) == TEMPLATES[name], (
f"YAML / inline drift for template {name!r}"
)
class TestSecurityFallbacks:
def test_oversized_file_falls_back_to_inline(self, tmp_path, monkeypatch):
"""v0.39.0 — _MAX_TEMPLATE_BYTES guard."""
import soup_cli.templates as tpl_mod
fake_dir = tmp_path / "templates"
fake_dir.mkdir()
(fake_dir / "manifest.json").write_text(
json.dumps({"templates": {"chat": "chat.yaml"}, "version": 1})
)
# Write a 300 KB file (> 256 KB cap)
(fake_dir / "chat.yaml").write_text("x" * (300 * 1024))
monkeypatch.setattr(tpl_mod, "_templates_dir", lambda: fake_dir)
# Should fall back to inline TEMPLATES["chat"], not return the giant blob.
body = tpl_mod.load_template("chat")
from soup_cli.config.schema import TEMPLATES
assert body == TEMPLATES["chat"]
assert len(body) < 256 * 1024
def test_crafted_manifest_outside_dir_falls_back(self, tmp_path, monkeypatch):
"""A manifest entry with a relative path that escapes must be ignored."""
import soup_cli.templates as tpl_mod
fake_dir = tmp_path / "templates"
fake_dir.mkdir()
# Create a file OUTSIDE the templates dir
(tmp_path / "secret.yaml").write_text("LEAKED CONTENT")
# Crafted manifest pointing to it via filename traversal-like component
# _validate_name will reject any name with `..`/`/`/`\` so this also
# exercises the manifest-tampering rejection chain.
(fake_dir / "manifest.json").write_text(
json.dumps({"templates": {"chat": "../secret.yaml"}, "version": 1})
)
monkeypatch.setattr(tpl_mod, "_templates_dir", lambda: fake_dir)
body = tpl_mod.load_template("chat")
from soup_cli.config.schema import TEMPLATES
# Must NOT contain the leaked content; falls back to inline.
assert "LEAKED CONTENT" not in (body or "")
assert body == TEMPLATES["chat"]
class TestInitUsesRegistry:
def test_init_command_lists_template_options(self):
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", "--help"])
# init --help should still succeed after the migration
assert result.exit_code == 0, (result.output, repr(result.exception))