"""v0.53.2 — Modality II live trainers. Covers: - #137 ``apply_reasoning_effort_prefix`` system-prompt injector + ``train_on_eot`` parameter on :func:`build_assistant_only_labels` + ``build_format_row`` wiring. - #135 Live ``apply_ebft_loss`` (structured / strided) and ``apply_gdpo_loss`` (standard / length_normalized / margin) pure-tensor kernels + ``attach_ebft_compute_loss`` (SFT) / ``attach_gdpo_compute_loss`` (DPO) hooks. - #133 ``DistillTrainerWrapper`` + ``build_distill_trainer`` factory + ``commands/train.py`` ``task='distill'`` routing. - #132 ``ClassifierTrainerWrapper`` + ``build_classifier_trainer`` factory + ``commands/train.py`` ``task in {classifier, reranker, cross_encoder}`` routing. Deferred to a follow-up patch: - #71 ONNX export GPU-bound smoke — recorded in ``tests/qa/v053_qa.md``. """ from __future__ import annotations import math from typing import Any, Optional import pytest # --------------------------------------------------------------------------- # #137 — reasoning_effort prompt-prefix injector # --------------------------------------------------------------------------- class TestReasoningEffortPrefix: def test_inserts_system_message_when_none(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix messages = [{"role": "user", "content": "hi"}] out = apply_reasoning_effort_prefix(messages, "high") assert out[0]["role"] == "system" assert "<|reasoning_effort|>high<|/reasoning_effort|>" in out[0]["content"] # User message preserved at index 1 assert out[1] == {"role": "user", "content": "hi"} def test_prepends_to_existing_system_message(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix messages = [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "hi"}, ] out = apply_reasoning_effort_prefix(messages, "low") assert out[0]["role"] == "system" # Both the tag AND original content are present assert "<|reasoning_effort|>low<|/reasoning_effort|>" in out[0]["content"] assert "You are helpful." in out[0]["content"] # User message unchanged assert out[1] == {"role": "user", "content": "hi"} def test_does_not_mutate_input(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix original = [{"role": "user", "content": "hi"}] snapshot = [dict(m) for m in original] _ = apply_reasoning_effort_prefix(original, "medium") assert original == snapshot @pytest.mark.parametrize("level", ["low", "medium", "high"]) def test_accepts_canonical_levels(self, level: str) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix out = apply_reasoning_effort_prefix([{"role": "user", "content": "x"}], level) assert f"<|reasoning_effort|>{level}<|/reasoning_effort|>" in out[0]["content"] def test_case_insensitive_level(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix out = apply_reasoning_effort_prefix([{"role": "user", "content": "x"}], "HIGH") # Canonical lower-case is emitted assert "<|reasoning_effort|>high<|/reasoning_effort|>" in out[0]["content"] def test_rejects_unknown_level(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix with pytest.raises(ValueError, match="not supported"): apply_reasoning_effort_prefix([{"role": "user", "content": "x"}], "extreme") def test_rejects_bool_level(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix with pytest.raises(TypeError, match="must not be bool"): apply_reasoning_effort_prefix([{"role": "user", "content": "x"}], True) # type: ignore[arg-type] def test_rejects_non_list_messages(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix with pytest.raises(TypeError, match="messages must be a list"): apply_reasoning_effort_prefix("hi", "low") # type: ignore[arg-type] def test_rejects_empty_messages(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix with pytest.raises(ValueError, match="empty"): apply_reasoning_effort_prefix([], "low") def test_rejects_non_dict_message(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix with pytest.raises(TypeError, match="must be dict"): apply_reasoning_effort_prefix(["not a dict"], "low") # type: ignore[list-item] # --------------------------------------------------------------------------- # #137 — train_on_eot loss-mask extension # --------------------------------------------------------------------------- class _FakeTokenizer: """Minimal tokenizer with chat_template + EOS token for loss-mask tests. Renders each message as ``role:content`` joined by a single space, where ```` is token id 9. Each character becomes a token id (its Unicode code point) so we can verify mask positions exactly. ``role:`` prefix is the role's first char + colon (codepoints). """ chat_template = "fake" eos_token = "" eos_token_id = 9 def apply_chat_template( self, messages: Any, tokenize: bool = True, add_generation_prompt: bool = False, return_assistant_tokens_mask: bool = False, add_special_tokens: bool = True, **kwargs: Any, ) -> Any: ids: list[int] = [] mask: list[int] = [] for msg in messages: role = msg["role"] content = msg["content"] is_assistant = role == "assistant" chunk_ids = [ord(c) for c in f"{role[0]}:{content}"] ids.extend(chunk_ids) mask.extend([1 if is_assistant else 0] * len(chunk_ids)) # EOT token after each message ids.append(self.eos_token_id) mask.append(1 if is_assistant else 0) if not tokenize: return "".join(chr(i) for i in ids) if return_assistant_tokens_mask: return {"input_ids": ids, "assistant_masks": mask} return ids class _EotBoundaryTokenizer: """Preferred-path fake whose assistant_mask EXCLUDES the trailing EOT. Mirrors a real chat template whose ``{% generation %}`` block wraps only the assistant content, leaving the EOT token outside the mask — which is exactly the case ``train_on_eot`` is designed to handle. """ chat_template = "fake" eos_token = "" eos_token_id = 9 def apply_chat_template( self, messages: Any, tokenize: bool = True, add_generation_prompt: bool = False, return_assistant_tokens_mask: bool = False, return_dict: bool = False, add_special_tokens: bool = True, **kwargs: Any, ) -> Any: ids: list[int] = [] mask: list[int] = [] for msg in messages: role = msg["role"] content = msg["content"] is_assistant = role == "assistant" chunk_ids = [ord(c) for c in f"{role[0]}:{content}"] ids.extend(chunk_ids) mask.extend([1 if is_assistant else 0] * len(chunk_ids)) # EOT token after each message — ALWAYS unmasked (0) in this fake. ids.append(self.eos_token_id) mask.append(0) if not tokenize: return "".join(chr(i) for i in ids) if return_assistant_tokens_mask and return_dict: return {"input_ids": ids, "assistant_masks": mask} return ids class TestTrainOnEot: def test_include_eot_default_false_masks_eot(self) -> None: """Existing behaviour — EOT *outside* the assistant mask is IGNORE.""" from soup_cli.data.loss_mask import IGNORE_INDEX, build_assistant_only_labels tok = _EotBoundaryTokenizer() messages = [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}, ] result = build_assistant_only_labels(messages, tok, max_length=64) labels = result["labels"] # Layout from _EotBoundaryTokenizer: u:hia:ok # mask: 0 0 0 0 0 0 0 1 1 0 # With include_eot=False the trailing EOT (id=9) at the end is # IGNORE_INDEX. assert labels[-1] == IGNORE_INDEX def test_include_eot_true_extends_label_to_eos(self) -> None: from soup_cli.data.loss_mask import ( IGNORE_INDEX, build_assistant_only_labels, ) tok = _EotBoundaryTokenizer() messages = [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}, ] without_eot = build_assistant_only_labels(messages, tok, max_length=64) with_eot = build_assistant_only_labels( messages, tok, max_length=64, include_eot=True ) # With include_eot=True, the EOT (id=9) immediately following an # assistant span is KEPT (not IGNORE), so the unmasked count is # strictly higher by exactly the number of assistant turns. n_kept_no = sum(1 for x in without_eot["labels"] if x != IGNORE_INDEX) n_kept_yes = sum(1 for x in with_eot["labels"] if x != IGNORE_INDEX) assert n_kept_yes - n_kept_no == 1 # Last token (the trailing EOT) is now KEPT. assert with_eot["labels"][-1] != IGNORE_INDEX assert with_eot["labels"][-1] == 9 def test_include_eot_must_be_bool(self) -> None: from soup_cli.data.loss_mask import build_assistant_only_labels with pytest.raises(TypeError, match="include_eot must be bool"): build_assistant_only_labels( [{"role": "user", "content": "x"}, {"role": "assistant", "content": "y"}], _FakeTokenizer(), max_length=64, include_eot="yes", # type: ignore[arg-type] ) # --------------------------------------------------------------------------- # #135 — EBFT live loss kernel # --------------------------------------------------------------------------- def _torch_or_skip(): try: import torch # noqa: F401 return torch except Exception: # pragma: no cover - CI without torch pytest.skip("torch not available") class TestEbftLossLive: def test_structured_returns_finite_scalar(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss logits = torch.randn(2, 4, 8, requires_grad=True) labels = torch.tensor([[0, 1, 2, -100], [3, 4, -100, -100]]) loss = apply_ebft_loss(logits, labels, variant="structured", temperature=1.0) assert loss.ndim == 0 assert torch.isfinite(loss) # Gradient flows loss.backward() assert logits.grad is not None def test_strided_returns_finite_scalar(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss logits = torch.randn(2, 4, 8) labels = torch.tensor([[0, 1, 2, 3], [4, 5, 6, 7]]) loss = apply_ebft_loss( logits, labels, variant="strided", temperature=0.5, stride=2 ) assert loss.ndim == 0 assert torch.isfinite(loss) def test_temperature_scales_loss(self) -> None: """Lower temperature sharpens the energy distribution → different loss.""" torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss torch.manual_seed(0) logits = torch.randn(1, 3, 5) labels = torch.tensor([[0, 1, 2]]) hot = apply_ebft_loss(logits, labels, variant="structured", temperature=2.0) cold = apply_ebft_loss(logits, labels, variant="structured", temperature=0.5) assert not math.isclose(float(hot), float(cold), rel_tol=1e-6) def test_unknown_variant_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss with pytest.raises(ValueError, match="not supported"): apply_ebft_loss( torch.randn(1, 2, 3), torch.tensor([[0, 1]]), variant="bogus", temperature=1.0, ) def test_temperature_validated(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss with pytest.raises(ValueError): apply_ebft_loss( torch.randn(1, 2, 3), torch.tensor([[0, 1]]), variant="structured", temperature=float("nan"), ) def test_all_ignore_labels_returns_zero(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss logits = torch.randn(2, 3, 4) labels = torch.full((2, 3), -100, dtype=torch.long) loss = apply_ebft_loss(logits, labels, variant="structured", temperature=1.0) assert float(loss) == 0.0 def test_shape_mismatch_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss with pytest.raises(ValueError, match="shape"): apply_ebft_loss( torch.randn(2, 4, 8), torch.tensor([[0, 1, 2]]), # seq=3 vs logits seq=4 variant="structured", temperature=1.0, ) # --------------------------------------------------------------------------- # #135 — GDPO live loss kernel # --------------------------------------------------------------------------- class TestGdpoLossLive: def test_standard_returns_finite_scalar(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss pol_chosen = torch.tensor([-2.0, -1.5, -3.0], requires_grad=True) pol_rejected = torch.tensor([-3.5, -2.5, -4.0], requires_grad=True) ref_chosen = torch.tensor([-2.2, -1.8, -3.1]) ref_rejected = torch.tensor([-3.2, -2.4, -3.9]) loss = apply_gdpo_loss( policy_chosen_logps=pol_chosen, policy_rejected_logps=pol_rejected, ref_chosen_logps=ref_chosen, ref_rejected_logps=ref_rejected, variant="standard", beta=0.1, ) assert loss.ndim == 0 assert torch.isfinite(loss) loss.backward() assert pol_chosen.grad is not None def test_length_normalized_uses_lengths(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss pol_chosen = torch.tensor([-10.0, -8.0]) pol_rejected = torch.tensor([-12.0, -10.0]) # Length-normalized uses chosen_lens / rejected_lens loss = apply_gdpo_loss( policy_chosen_logps=pol_chosen, policy_rejected_logps=pol_rejected, variant="length_normalized", beta=0.5, chosen_lens=torch.tensor([5.0, 4.0]), rejected_lens=torch.tensor([6.0, 5.0]), ) assert loss.ndim == 0 assert torch.isfinite(loss) def test_margin_includes_margin_term(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss pol_chosen = torch.tensor([-2.0]) pol_rejected = torch.tensor([-3.0]) ref_chosen = torch.tensor([-2.0]) ref_rejected = torch.tensor([-3.0]) no_margin = apply_gdpo_loss( policy_chosen_logps=pol_chosen, policy_rejected_logps=pol_rejected, ref_chosen_logps=ref_chosen, ref_rejected_logps=ref_rejected, variant="margin", beta=0.1, margin=0.0, ) with_margin = apply_gdpo_loss( policy_chosen_logps=pol_chosen, policy_rejected_logps=pol_rejected, ref_chosen_logps=ref_chosen, ref_rejected_logps=ref_rejected, variant="margin", beta=0.1, margin=1.0, ) # Larger margin → larger loss (harder to satisfy) assert float(with_margin) > float(no_margin) def test_unknown_variant_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(ValueError, match="not supported"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, variant="bogus", beta=0.1, ) def test_standard_requires_reference(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(ValueError, match="reference"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, variant="standard", beta=0.1, ) def test_length_normalized_requires_lengths(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(ValueError, match="chosen_lens"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, variant="length_normalized", beta=0.1, ) def test_beta_validated(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(ValueError, match="beta"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, ref_chosen_logps=t, ref_rejected_logps=t, variant="standard", beta=-0.1, ) def test_beta_rejects_bool(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(TypeError, match="bool"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, ref_chosen_logps=t, ref_rejected_logps=t, variant="standard", beta=True, # type: ignore[arg-type] ) def test_shape_mismatch_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss with pytest.raises(ValueError, match="shape"): apply_gdpo_loss( policy_chosen_logps=torch.zeros(2), policy_rejected_logps=torch.zeros(3), ref_chosen_logps=torch.zeros(2), ref_rejected_logps=torch.zeros(2), variant="standard", beta=0.1, ) # --------------------------------------------------------------------------- # Cross-cutting — stubs lifted # --------------------------------------------------------------------------- def test_apply_ebft_loss_no_longer_raises_not_implemented() -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss # Should succeed (not raise NotImplementedError) — confirms stub lifted. apply_ebft_loss( torch.randn(1, 2, 3), torch.tensor([[0, 1]]), variant="structured", temperature=1.0, ) def test_apply_gdpo_loss_no_longer_raises_not_implemented() -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, ref_chosen_logps=t, ref_rejected_logps=t, variant="standard", beta=0.1, ) # --------------------------------------------------------------------------- # #135 — attach hooks (live wiring into SFT + DPO trainers) # --------------------------------------------------------------------------- class _StubTrainer: """Minimal trainer surface for hook tests — captures compute_loss calls.""" def __init__(self) -> None: self.call_log: list[tuple] = [] def _compute_loss(model, inputs, return_outputs=False, num_items_in_batch=None): self.call_log.append(("compute_loss", return_outputs)) torch = _torch_or_skip() outputs = type("Out", (), {"logits": torch.zeros(1, 2, 3)})() loss = torch.tensor(0.5) return (loss, outputs) if return_outputs else loss self.compute_loss = _compute_loss class TestAttachEbftComputeLoss: def test_no_op_when_variant_unset(self) -> None: from soup_cli.utils.ebft_gdpo import attach_ebft_compute_loss trainer = _StubTrainer() tcfg = type("Tcfg", (), {"ebft_variant": None})() assert attach_ebft_compute_loss(trainer, tcfg) is False def test_wraps_when_variant_set(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import attach_ebft_compute_loss trainer = _StubTrainer() original = trainer.compute_loss tcfg = type( "Tcfg", (), {"ebft_variant": "structured", "ebft_temperature": 1.0} )() assert attach_ebft_compute_loss(trainer, tcfg) is True assert trainer.compute_loss is not original # Call the wrapped function — verify it returns a scalar tensor and # calls the original (loss should be CE + EBFT term). labels = torch.tensor([[0, 1]]) loss = trainer.compute_loss(None, {"labels": labels}, return_outputs=False) assert torch.is_tensor(loss) # Original was called with return_outputs=True. assert trainer.call_log[-1] == ("compute_loss", True) def test_invalid_variant_rejected(self) -> None: from soup_cli.utils.ebft_gdpo import attach_ebft_compute_loss trainer = _StubTrainer() tcfg = type("Tcfg", (), {"ebft_variant": "bogus"})() with pytest.raises(ValueError, match="not supported"): attach_ebft_compute_loss(trainer, tcfg) class _StubDpoTrainer: """Stand-in for TRL DPOTrainer's dpo_loss surface.""" def __init__(self) -> None: def _dpo_loss(*args, **kwargs): return None # Original returns 3-tuple; stub returns sentinel. self.dpo_loss = _dpo_loss class TestAttachGdpoComputeLoss: def test_no_op_when_variant_unset(self) -> None: from soup_cli.utils.ebft_gdpo import attach_gdpo_compute_loss trainer = _StubDpoTrainer() tcfg = type("Tcfg", (), {"gdpo_variant": None})() assert attach_gdpo_compute_loss(trainer, tcfg) is False def test_no_op_when_trainer_lacks_dpo_loss(self) -> None: from soup_cli.utils.ebft_gdpo import attach_gdpo_compute_loss trainer = object() tcfg = type("Tcfg", (), {"gdpo_variant": "standard"})() assert attach_gdpo_compute_loss(trainer, tcfg) is False def test_wraps_and_returns_trl_shape(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import attach_gdpo_compute_loss trainer = _StubDpoTrainer() tcfg = type( "Tcfg", (), {"gdpo_variant": "standard", "dpo_beta": 0.1, "dpo_margin": 0.0}, )() assert attach_gdpo_compute_loss(trainer, tcfg) is True pol_c = torch.tensor([-1.0, -2.0]) pol_r = torch.tensor([-2.0, -3.0]) ref_c = torch.tensor([-1.1, -2.1]) ref_r = torch.tensor([-2.1, -3.1]) losses, chosen_rewards, rejected_rewards = trainer.dpo_loss( pol_c, pol_r, ref_c, ref_r ) assert losses.shape == pol_c.shape assert chosen_rewards.shape == pol_c.shape assert rejected_rewards.shape == pol_r.shape # --------------------------------------------------------------------------- # #133 — DistillTrainerWrapper + distill divergence kernel # --------------------------------------------------------------------------- class TestDistillDivergenceKernel: @pytest.mark.parametrize("divergence", ["forward_kl", "reverse_kl", "js"]) def test_divergence_returns_finite_scalar(self, divergence: str) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term student = torch.randn(2, 4, 8, requires_grad=True) teacher = torch.randn(2, 4, 8) out = _compute_distill_term(student, teacher, divergence, temperature=2.0) assert out.ndim == 0 assert torch.isfinite(out) out.backward() assert student.grad is not None def test_unknown_divergence_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term with pytest.raises(ValueError, match="Unknown divergence"): _compute_distill_term( torch.zeros(1, 2, 3), torch.zeros(1, 2, 3), "bogus", 1.0 ) def test_identical_logits_zero_kl(self) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term logits = torch.randn(1, 3, 5) out = _compute_distill_term(logits, logits.clone(), "forward_kl", 1.0) assert abs(float(out)) < 1e-5 class TestDistillWrapper: def test_imports_cleanly(self) -> None: from soup_cli.trainer.distill import DistillTrainerWrapper # noqa: F401 def test_train_before_setup_raises(self) -> None: from soup_cli.config.loader import load_config_from_string from soup_cli.trainer.distill import DistillTrainerWrapper cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: distill training: teacher_model: sshleifer/tiny-gpt2 distill_temperature: 2.0 distill_divergence: forward_kl data: train: ./fake.jsonl """ ) wrapper = DistillTrainerWrapper(cfg, device="cpu") with pytest.raises(RuntimeError, match="before setup"): wrapper.train() def test_build_distill_trainer_factory_lifted(self) -> None: from soup_cli.config.loader import load_config_from_string from soup_cli.utils.distill import build_distill_trainer cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: distill training: teacher_model: sshleifer/tiny-gpt2 data: train: ./fake.jsonl """ ) # Lifted from NotImplementedError in v0.53.2 — returns wrapper. wrapper = build_distill_trainer(cfg, device="cpu") from soup_cli.trainer.distill import DistillTrainerWrapper assert isinstance(wrapper, DistillTrainerWrapper) # --------------------------------------------------------------------------- # #132 — ClassifierTrainerWrapper + helpers # --------------------------------------------------------------------------- class TestClassifierWrapperHelpers: def test_row_to_text_with_text_field(self) -> None: from soup_cli.trainer.classifier import _row_to_text assert _row_to_text({"text": "hello"}) == "hello" def test_row_to_text_joins_messages(self) -> None: from soup_cli.trainer.classifier import _row_to_text out = _row_to_text( { "messages": [ {"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}, ] } ) assert "a" in out and "b" in out def test_row_to_text_missing_field_raises(self) -> None: from soup_cli.trainer.classifier import _row_to_text with pytest.raises(ValueError, match="missing 'text'"): _row_to_text({"label": 0}) def test_row_to_pair_text_ab(self) -> None: from soup_cli.trainer.classifier import _row_to_pair a, b = _row_to_pair({"text_a": "x", "text_b": "y"}) assert (a, b) == ("x", "y") def test_row_to_pair_question_answer(self) -> None: from soup_cli.trainer.classifier import _row_to_pair a, b = _row_to_pair({"question": "q", "answer": "a"}) assert (a, b) == ("q", "a") def test_row_to_pair_missing_raises(self) -> None: from soup_cli.trainer.classifier import _row_to_pair with pytest.raises(ValueError, match="text_a"): _row_to_pair({"text": "single"}) @pytest.mark.parametrize("idx", [0, 1, 2]) def test_label_index_int_in_range(self, idx: int) -> None: from soup_cli.trainer.classifier import _label_index assert _label_index(idx, None, num_labels=3) == idx def test_label_index_int_out_of_range_rejected(self) -> None: from soup_cli.trainer.classifier import _label_index with pytest.raises(ValueError, match="out of range"): _label_index(5, None, num_labels=3) def test_label_index_string_via_label_names(self) -> None: from soup_cli.trainer.classifier import _label_index assert _label_index("pos", ["neg", "pos"], num_labels=2) == 1 def test_label_index_string_without_names_rejected(self) -> None: from soup_cli.trainer.classifier import _label_index with pytest.raises(ValueError, match="label_names is unset"): _label_index("pos", None, num_labels=2) def test_label_index_bool_rejected(self) -> None: from soup_cli.trainer.classifier import _label_index # Project policy (v0.30.0 Candidate / v0.39.0 ReLoRAPolicy / v0.41.0 # Part B): bool-as-int violations raise TypeError. with pytest.raises(TypeError, match="bool"): _label_index(True, None, num_labels=2) def test_normalise_label_multi_label_from_list(self) -> None: from soup_cli.trainer.classifier import _normalise_label vec = _normalise_label( [0, 2], label_names=None, num_labels=3, multi_label=True ) assert vec == [1.0, 0.0, 1.0] class TestClassifierWrapper: def test_imports_cleanly(self) -> None: from soup_cli.trainer.classifier import ClassifierTrainerWrapper # noqa: F401 def test_train_before_setup_raises(self) -> None: from soup_cli.config.loader import load_config_from_string from soup_cli.trainer.classifier import ClassifierTrainerWrapper cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: classifier training: num_labels: 3 data: train: ./fake.jsonl """ ) wrapper = ClassifierTrainerWrapper(cfg, device="cpu") with pytest.raises(RuntimeError, match="before setup"): wrapper.train() def test_build_classifier_trainer_factory_lifted(self) -> None: from soup_cli.config.loader import load_config_from_string from soup_cli.utils.classifier import build_classifier_trainer cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: classifier training: num_labels: 2 data: train: ./fake.jsonl """ ) wrapper = build_classifier_trainer(cfg, device="cpu") from soup_cli.trainer.classifier import ClassifierTrainerWrapper assert isinstance(wrapper, ClassifierTrainerWrapper) # --------------------------------------------------------------------------- # commands/train.py routing — source-level audit # --------------------------------------------------------------------------- class TestTrainRouting: """Source-grep audit — looks for the *instantiation site* of each wrapper. Bare ``"DistillTrainerWrapper" in src`` would pass on a comment mentioning the class. ``"DistillTrainerWrapper(cfg, **trainer_kwargs)"`` requires the actual call expression to be present, which is much harder to satisfy by accident. """ def test_distill_routed(self) -> None: from soup_cli.commands import train as train_cmd src = __import__("inspect").getsource(train_cmd) assert 'cfg.task == "distill"' in src # Require the actual instantiation expression, not just the bare name. assert "DistillTrainerWrapper(cfg, **trainer_kwargs)" in src def test_classifier_family_routed(self) -> None: from soup_cli.commands import train as train_cmd src = __import__("inspect").getsource(train_cmd) # Tuple membership in the if-branch is the load-bearing pattern. assert ( 'cfg.task in ("classifier", "reranker", "cross_encoder")' in src ) # Instantiation expression — not just the class name. assert "ClassifierTrainerWrapper(cfg, **trainer_kwargs)" in src # --------------------------------------------------------------------------- # #137 — build_format_row wiring of reasoning_effort + train_on_eot # --------------------------------------------------------------------------- class _Tcfg: def __init__( self, reasoning_effort: Optional[str] = None, train_on_eot: bool = False, ) -> None: self.reasoning_effort = reasoning_effort self.train_on_eot = train_on_eot def _make_data_cfg( train_on_responses_only: bool = True, chat_template: Optional[str] = None ): """Minimal duck-typed DataConfig for sft_format tests.""" obj = type( "DataCfg", (), { "train_on_responses_only": train_on_responses_only, "train_on_messages_with_train_field": False, "max_length": 64, "chat_template": chat_template, }, )() return obj class TestFormatRowReasoningEffort: def test_no_reasoning_effort_passthrough(self) -> None: from soup_cli.data.sft_format import build_format_row format_row = build_format_row( tokenizer=_EotBoundaryTokenizer(), data_cfg=_make_data_cfg(), training_cfg=_Tcfg(reasoning_effort=None), ) row = { "messages": [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}, ] } out = format_row(row) # Should produce {input_ids, labels, attention_mask} assert set(out) >= {"input_ids", "labels", "attention_mask"} def test_reasoning_effort_injected_via_format_row(self) -> None: from soup_cli.data.sft_format import build_format_row captured_messages: list = [] class _CapturingTok(_EotBoundaryTokenizer): def apply_chat_template(self, messages, **kwargs): captured_messages.append([dict(m) for m in messages]) return super().apply_chat_template(messages, **kwargs) format_row = build_format_row( tokenizer=_CapturingTok(), data_cfg=_make_data_cfg(), training_cfg=_Tcfg(reasoning_effort="high"), ) row = { "messages": [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}, ] } _ = format_row(row) # First captured render should have a system message with the tag. first = captured_messages[0] assert first[0]["role"] == "system" assert "<|reasoning_effort|>high<|/reasoning_effort|>" in first[0]["content"] def test_does_not_mutate_original_row(self) -> None: from soup_cli.data.sft_format import build_format_row format_row = build_format_row( tokenizer=_EotBoundaryTokenizer(), data_cfg=_make_data_cfg(), training_cfg=_Tcfg(reasoning_effort="low"), ) row = { "messages": [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}, ] } original_msgs = [dict(m) for m in row["messages"]] _ = format_row(row) assert row["messages"] == original_msgs class TestFormatRowTrainOnEot: def test_train_on_eot_extends_loss_mask(self) -> None: from soup_cli.data.loss_mask import IGNORE_INDEX from soup_cli.data.sft_format import build_format_row tok = _EotBoundaryTokenizer() row = { "messages": [ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "ok"}, ] } without = build_format_row( tokenizer=tok, data_cfg=_make_data_cfg(), training_cfg=_Tcfg(train_on_eot=False), )(row) # Need a fresh tokenizer instance because the override is module-level tok2 = _EotBoundaryTokenizer() with_eot = build_format_row( tokenizer=tok2, data_cfg=_make_data_cfg(), training_cfg=_Tcfg(train_on_eot=True), )(row) n_no = sum(1 for x in without["labels"] if x != IGNORE_INDEX) n_yes = sum(1 for x in with_eot["labels"] if x != IGNORE_INDEX) assert n_yes - n_no == 1 # --------------------------------------------------------------------------- # Schema gates — classifier and distill still validate # --------------------------------------------------------------------------- def test_distill_task_loads_with_teacher() -> None: from soup_cli.config.loader import load_config_from_string cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: distill training: teacher_model: sshleifer/tiny-gpt2 distill_temperature: 2.0 distill_divergence: forward_kl data: train: ./fake.jsonl """ ) assert cfg.task == "distill" assert cfg.training.teacher_model == "sshleifer/tiny-gpt2" def test_classifier_task_requires_num_labels() -> None: from soup_cli.config.loader import load_config_from_string # ``load_config_from_string`` re-raises pydantic validation as ValueError. with pytest.raises(ValueError, match="num_labels"): load_config_from_string( """ base: sshleifer/tiny-gpt2 task: classifier data: train: ./fake.jsonl """ ) # --------------------------------------------------------------------------- # Review-fix coverage (v0.53.2 TDD + security review followups) # --------------------------------------------------------------------------- class TestEbftAttachLabelsNone: """tdd-review C1 — wrapped compute_loss must tolerate inputs without labels.""" def test_labels_missing_returns_ce_only(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import attach_ebft_compute_loss trainer = _StubTrainer() tcfg = type( "Tcfg", (), {"ebft_variant": "structured", "ebft_temperature": 1.0}, )() attach_ebft_compute_loss(trainer, tcfg) # No "labels" key — wrapper must fall through to CE without crashing. loss = trainer.compute_loss(None, {}, return_outputs=False) assert torch.is_tensor(loss) class TestEbftAttachIdempotent: def test_double_wrap_is_no_op(self) -> None: from soup_cli.utils.ebft_gdpo import attach_ebft_compute_loss trainer = _StubTrainer() tcfg = type( "Tcfg", (), {"ebft_variant": "structured", "ebft_temperature": 1.0}, )() first = attach_ebft_compute_loss(trainer, tcfg) second = attach_ebft_compute_loss(trainer, tcfg) assert first is True assert second is False assert getattr(trainer, "_soup_ebft_wrapped", False) is True class TestGdpoAttachIdempotent: def test_double_wrap_is_no_op(self) -> None: from soup_cli.utils.ebft_gdpo import attach_gdpo_compute_loss trainer = _StubDpoTrainer() tcfg = type( "Tcfg", (), {"gdpo_variant": "standard", "dpo_beta": 0.1, "dpo_margin": 0.0}, )() first = attach_gdpo_compute_loss(trainer, tcfg) second = attach_gdpo_compute_loss(trainer, tcfg) assert first is True assert second is False assert getattr(trainer, "_soup_gdpo_wrapped", False) is True class TestGdpoAttachLengthNormalizedForwardsLens: """python-review MEDIUM — length_normalized requires chosen_lens/rejected_lens.""" def test_length_normalized_via_kwargs(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import attach_gdpo_compute_loss trainer = _StubDpoTrainer() tcfg = type( "Tcfg", (), { "gdpo_variant": "length_normalized", "dpo_beta": 0.5, "dpo_margin": 0.0, }, )() attach_gdpo_compute_loss(trainer, tcfg) pol_c = torch.tensor([-10.0, -8.0]) pol_r = torch.tensor([-12.0, -10.0]) ref_c = torch.zeros(2) ref_r = torch.zeros(2) losses, _, _ = trainer.dpo_loss( pol_c, pol_r, ref_c, ref_r, chosen_lens=torch.tensor([5.0, 4.0]), rejected_lens=torch.tensor([6.0, 5.0]), ) assert losses.shape == pol_c.shape def test_length_normalized_via_positional(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import attach_gdpo_compute_loss trainer = _StubDpoTrainer() tcfg = type( "Tcfg", (), { "gdpo_variant": "length_normalized", "dpo_beta": 0.5, "dpo_margin": 0.0, }, )() attach_gdpo_compute_loss(trainer, tcfg) pol_c = torch.tensor([-10.0, -8.0]) pol_r = torch.tensor([-12.0, -10.0]) # Positional chosen_lens / rejected_lens (newer TRL signatures may # pass these positionally). losses, _, _ = trainer.dpo_loss( pol_c, pol_r, torch.zeros(2), torch.zeros(2), torch.tensor([5.0, 4.0]), torch.tensor([6.0, 5.0]), ) assert losses.shape == pol_c.shape class TestExtendMaskIdempotency: """tdd-review C2 — _extend_mask_to_eot must be idempotent.""" def test_second_pass_no_op(self) -> None: from soup_cli.data.loss_mask import _extend_mask_to_eot ids = [10, 11, 9, 20, 21, 9] mask = [1, 1, 0, 0, 0, 0] once = _extend_mask_to_eot(ids, mask, eos_token_id=9) twice = _extend_mask_to_eot(ids, once, eos_token_id=9) assert once == twice def test_leading_eos_not_marked(self) -> None: from soup_cli.data.loss_mask import _extend_mask_to_eot ids = [9, 10, 11, 9] mask = [0, 1, 1, 0] out = _extend_mask_to_eot(ids, mask, eos_token_id=9) assert out[0] == 0 # leading EOS not absorbed assert out[3] == 1 # trailing EOS absorbed def test_two_assistant_spans_both_get_eos(self) -> None: from soup_cli.data.loss_mask import _extend_mask_to_eot ids = [1, 2, 9, 9, 3, 4, 9] mask = [0, 1, 0, 0, 0, 1, 0] out = _extend_mask_to_eot(ids, mask, eos_token_id=9) assert out[2] == 1 # consecutive EOS absorbed assert out[3] == 1 assert out[6] == 1 class TestResolveEosTokenId: """python-review MEDIUM — handle list/str/None/bool eos_token_id.""" def test_int(self) -> None: from soup_cli.data.loss_mask import _resolve_eos_token_id class T: eos_token_id = 9 assert _resolve_eos_token_id(T()) == 9 def test_list_picks_first_int(self) -> None: from soup_cli.data.loss_mask import _resolve_eos_token_id class T: eos_token_id = [128001, 128009] # Llama 3 style assert _resolve_eos_token_id(T()) == 128001 def test_str_returns_none(self) -> None: from soup_cli.data.loss_mask import _resolve_eos_token_id class T: eos_token_id = "9" assert _resolve_eos_token_id(T()) is None def test_none_returns_none(self) -> None: from soup_cli.data.loss_mask import _resolve_eos_token_id class T: pass assert _resolve_eos_token_id(T()) is None def test_bool_returns_none(self) -> None: from soup_cli.data.loss_mask import _resolve_eos_token_id class T: eos_token_id = True assert _resolve_eos_token_id(T()) is None class TestComputeDistillTermValidation: """python-review MEDIUM / tdd-review H1 — temperature validation in kernel.""" def test_zero_temperature_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term with pytest.raises(ValueError, match="positive"): _compute_distill_term( torch.zeros(1, 2, 3), torch.zeros(1, 2, 3), "forward_kl", 0.0, ) def test_negative_temperature_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term with pytest.raises(ValueError, match="positive"): _compute_distill_term( torch.zeros(1, 2, 3), torch.zeros(1, 2, 3), "forward_kl", -1.0, ) def test_nan_temperature_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term with pytest.raises(ValueError, match="finite"): _compute_distill_term( torch.zeros(1, 2, 3), torch.zeros(1, 2, 3), "forward_kl", float("nan"), ) def test_bool_temperature_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.trainer.distill import _compute_distill_term with pytest.raises(TypeError, match="bool"): _compute_distill_term( torch.zeros(1, 2, 3), torch.zeros(1, 2, 3), "forward_kl", True, # type: ignore[arg-type] ) class TestReasoningEffortNullByte: """tdd-review H3 — null-byte in level rejected.""" def test_null_byte_rejected(self) -> None: from soup_cli.utils.reasoning_effort import apply_reasoning_effort_prefix with pytest.raises(ValueError, match="null"): apply_reasoning_effort_prefix( [{"role": "user", "content": "x"}], "lo\x00w" ) class TestEbftStrideValidation: """tdd-review M2 — stride bool / non-positive rejected.""" def test_stride_bool_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss with pytest.raises(TypeError, match="stride"): apply_ebft_loss( torch.randn(1, 2, 3), torch.tensor([[0, 1]]), variant="strided", temperature=1.0, stride=True, # type: ignore[arg-type] ) def test_stride_zero_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss with pytest.raises(ValueError, match="stride"): apply_ebft_loss( torch.randn(1, 2, 3), torch.tensor([[0, 1]]), variant="strided", temperature=1.0, stride=0, ) def test_stride_negative_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_ebft_loss with pytest.raises(ValueError, match="stride"): apply_ebft_loss( torch.randn(1, 2, 3), torch.tensor([[0, 1]]), variant="strided", temperature=1.0, stride=-1, ) class TestGdpoMarginValidation: """tdd-review M3 — margin bool / NaN rejected.""" def test_margin_bool_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(TypeError, match="margin"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, ref_chosen_logps=t, ref_rejected_logps=t, variant="margin", beta=0.1, margin=True, # type: ignore[arg-type] ) def test_margin_nan_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss t = torch.zeros(2) with pytest.raises(ValueError, match="finite"): apply_gdpo_loss( policy_chosen_logps=t, policy_rejected_logps=t, ref_chosen_logps=t, ref_rejected_logps=t, variant="margin", beta=0.1, margin=float("nan"), ) class TestGdpoRefShapeMismatch: """tdd-review M4 — ref logps shape mismatch vs policy.""" def test_ref_shape_mismatch_rejected(self) -> None: torch = _torch_or_skip() from soup_cli.utils.ebft_gdpo import apply_gdpo_loss with pytest.raises(ValueError, match="shape"): apply_gdpo_loss( policy_chosen_logps=torch.zeros(3), policy_rejected_logps=torch.zeros(3), ref_chosen_logps=torch.zeros(2), ref_rejected_logps=torch.zeros(3), variant="standard", beta=0.1, ) class TestRowToTextRejectsNonStrContent: """security-review M3 — non-str content raises rather than silent skip.""" def test_non_str_content_raises(self) -> None: from soup_cli.trainer.classifier import _row_to_text with pytest.raises(TypeError, match="must be str"): _row_to_text( { "messages": [ {"role": "user", "content": ["multi", "modal"]}, ] } ) def test_non_dict_message_silently_skipped(self) -> None: from soup_cli.trainer.classifier import _row_to_text out = _row_to_text( {"messages": ["not-a-dict", {"role": "user", "content": "real"}]} ) assert "real" in out class TestRowToPairRejectsNonStr: """security-review M4 — unchecked str() coercion fix.""" def test_non_str_text_a_rejected(self) -> None: from soup_cli.trainer.classifier import _row_to_pair with pytest.raises(TypeError, match="text_a"): _row_to_pair({"text_a": {"d": "ict"}, "text_b": "y"}) def test_non_str_text_b_rejected(self) -> None: from soup_cli.trainer.classifier import _row_to_pair with pytest.raises(TypeError, match="text_b"): _row_to_pair({"text_a": "x", "text_b": [1, 2]}) def test_non_str_question_rejected(self) -> None: from soup_cli.trainer.classifier import _row_to_pair with pytest.raises(TypeError, match="question"): _row_to_pair({"question": 42, "answer": "ok"}) class TestLabelIndexFurtherCoverage: """tdd-review M5 / M6 / M9.""" def test_single_label_list_raises(self) -> None: from soup_cli.trainer.classifier import _normalise_label with pytest.raises(TypeError): _normalise_label( [0, 1], label_names=None, num_labels=3, multi_label=False ) def test_string_not_in_label_names(self) -> None: from soup_cli.trainer.classifier import _label_index with pytest.raises(ValueError, match="not in training.label_names"): _label_index("unknown", ["pos", "neg"], num_labels=2) @pytest.mark.parametrize("bad", [None, 1.5, object()]) def test_unsupported_type_rejected(self, bad: object) -> None: from soup_cli.trainer.classifier import _label_index with pytest.raises(TypeError, match="must be int"): _label_index(bad, None, num_labels=3) class TestMultiLabelListCap: """security-review H2 — uncapped multi-label list DoS defense.""" def test_oversize_list_rejected(self) -> None: from soup_cli.trainer.classifier import _normalise_label # _MAX_MULTI_LABEL_ENTRIES is 1024. big = [0] * 2000 with pytest.raises(ValueError, match="too long"): _normalise_label( big, label_names=None, num_labels=3, multi_label=True ) class TestFactoryUnknownKwarg: """tdd-review L1 — factories must reject unknown kwargs loudly.""" def test_build_distill_trainer_unknown_kwarg(self) -> None: from soup_cli.config.loader import load_config_from_string from soup_cli.utils.distill import build_distill_trainer cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: distill training: teacher_model: sshleifer/tiny-gpt2 data: train: ./fake.jsonl """ ) with pytest.raises(TypeError): build_distill_trainer(cfg, device="cpu", nonexistent=True) def test_build_classifier_trainer_unknown_kwarg(self) -> None: from soup_cli.config.loader import load_config_from_string from soup_cli.utils.classifier import build_classifier_trainer cfg = load_config_from_string( """ base: sshleifer/tiny-gpt2 task: classifier training: num_labels: 2 data: train: ./fake.jsonl """ ) with pytest.raises(TypeError): build_classifier_trainer(cfg, device="cpu", nonexistent=True) class TestDistillSourceLevelGuards: """Regression guards for v0.53.2 wave 3 smoke-surfaced bugs.""" def test_uses_seq2seq_collator(self) -> None: """``DataCollatorForLanguageModeling`` does NOT pad labels — would crash on variable-length pre-tokenised rows. v0.53.2 wave 3 switched to ``DataCollatorForSeq2Seq``. Check the import statement specifically (the deprecated class name still appears in an explanatory comment).""" import inspect from soup_cli.trainer import distill as distill_mod src = inspect.getsource(distill_mod) # The actual import line must reference the Seq2Seq collator. assert "from transformers import DataCollatorForSeq2Seq" in src # No import of the wrong collator. assert "from transformers import DataCollatorForLanguageModeling" not in src def test_compute_loss_bridges_teacher_device(self) -> None: """Bug surfaced during CPU smoke on a CUDA-capable box: HF Trainer auto-moved the student to CUDA while the teacher stayed where loaded. compute_loss must bridge teacher inputs onto the teacher's device and teacher_logits back onto the student's device.""" import inspect from soup_cli.trainer import distill as distill_mod src = inspect.getsource(distill_mod) assert "teacher_device = next(teacher_ref.parameters()).device" in src assert ".to(student_logits.device)" in src # --------------------------------------------------------------------------- # Step 6f — failure-mode smoke: every cross-validator triggered by bad YAML # --------------------------------------------------------------------------- class TestFailureModeSmoke: """One YAML per cross-validator path the v0.53.2 surfaces add or trigger. Goal: prove the validators name the actual problem so users grep successfully, not "ValidationError" generic. """ def test_classifier_missing_num_labels(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="num_labels"): load_config_from_string( "base: x\ntask: classifier\ndata:\n train: ./f.jsonl\n" ) def test_classifier_label_names_length_mismatch(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="num_labels"): load_config_from_string( "base: x\ntask: classifier\ntraining:\n" " num_labels: 3\n label_names: [a, b]\n" "data:\n train: ./f.jsonl\n" ) def test_classifier_fields_outside_classifier_task(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="num_labels|classifier"): load_config_from_string( "base: x\ntask: sft\ntraining:\n num_labels: 3\n" "data:\n train: ./f.jsonl\n" ) def test_distill_missing_teacher(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="teacher_model"): load_config_from_string( "base: x\ntask: distill\ndata:\n train: ./f.jsonl\n" ) def test_distill_fields_outside_distill_task(self) -> None: from soup_cli.config.loader import load_config_from_string # teacher_model set on a non-distill task → rejected with named field. with pytest.raises(ValueError, match="teacher_model|distill"): load_config_from_string( "base: x\ntask: sft\ntraining:\n teacher_model: y\n" "data:\n train: ./f.jsonl\n" ) def test_reasoning_effort_on_non_sft_family_task(self) -> None: from soup_cli.config.loader import load_config_from_string # reasoning_effort is SFT-family-only; reject on grpo. with pytest.raises(ValueError, match="reasoning_effort"): load_config_from_string( "base: x\ntask: grpo\ntraining:\n reasoning_effort: high\n" "data:\n train: ./f.jsonl\n" ) def test_train_on_eot_on_non_sft_family_task(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="train_on_eot"): load_config_from_string( "base: x\ntask: grpo\ntraining:\n train_on_eot: true\n" "data:\n train: ./f.jsonl\n" ) def test_ebft_temperature_without_variant(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="ebft_variant"): load_config_from_string( "base: x\ntask: sft\ntraining:\n ebft_temperature: 1.0\n" "data:\n train: ./f.jsonl\n" ) def test_gdpo_variant_on_sft_rejected(self) -> None: from soup_cli.config.loader import load_config_from_string with pytest.raises(ValueError, match="gdpo_variant"): load_config_from_string( "base: x\ntask: sft\ntraining:\n gdpo_variant: standard\n" "data:\n train: ./f.jsonl\n" )