"""v0.53.11 — GRPO Plus finish + preference live (#123, #126, #127, #119, #68). Tests the math kernels + dispatch logic. Live GPU smoke runs are documented in plan.md and require a CUDA box. """ from __future__ import annotations import math import pytest # Skip the whole module when torch is unavailable — the math kernels are # torch-based by design. torch = pytest.importorskip("torch") # --------------------------------------------------------------------------- # #123 — GRPO variant live loss kernels # --------------------------------------------------------------------------- class TestGRPOVariantKernels: def _toy_inputs(self, batch=2, seq=4): logp_new = torch.zeros(batch, seq) logp_old = torch.zeros(batch, seq) advantages = torch.tensor([1.0, -1.0]) return logp_new, logp_old, advantages def test_standard_returns_none(self): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() assert apply_variant_loss( "standard", logp_new=logp_new, logp_old=logp_old, advantages=adv ) is None @pytest.mark.parametrize("variant", ["gspo", "dapo", "dr_grpo", "bnpo", "rft"]) def test_variant_returns_finite_scalar(self, variant): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() out = apply_variant_loss( variant, logp_new=logp_new, logp_old=logp_old, advantages=adv ) assert out is not None assert out.shape == () # scalar assert math.isfinite(float(out)) def test_two_sided_requires_delta(self): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() with pytest.raises(ValueError, match="grpo_delta"): apply_variant_loss( "two_sided", logp_new=logp_new, logp_old=logp_old, advantages=adv ) def test_two_sided_with_delta(self): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() out = apply_variant_loss( "two_sided", logp_new=logp_new, logp_old=logp_old, advantages=adv, delta=0.2, ) assert math.isfinite(float(out)) def test_completion_mask_zeros_padding(self): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() mask = torch.tensor([[1.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0]]) out = apply_variant_loss( "gspo", logp_new=logp_new, logp_old=logp_old, advantages=adv, completion_mask=mask, ) assert math.isfinite(float(out)) def test_bool_beta_rejected(self): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() with pytest.raises(TypeError, match="bool"): apply_variant_loss( "gspo", logp_new=logp_new, logp_old=logp_old, advantages=adv, beta=True, ) def test_unknown_variant_rejected(self): from soup_cli.utils.grpo_variants import apply_variant_loss logp_new, logp_old, adv = self._toy_inputs() with pytest.raises(ValueError, match="grpo_variant"): apply_variant_loss( "nonsense", logp_new=logp_new, logp_old=logp_old, advantages=adv ) def test_no_more_deferred_live(self): from soup_cli.utils.grpo_variants import _DEFERRED_LIVE assert _DEFERRED_LIVE == frozenset() def test_variant_metadata_live_wired(self): from soup_cli.utils.grpo_variants import _VARIANT_METADATA for name, spec in _VARIANT_METADATA.items(): assert spec.live_wired is True, f"{name} not flipped to live" # --------------------------------------------------------------------------- # #119 — LongLoRA forward override # --------------------------------------------------------------------------- class TestLongLoRAShift: def test_shift_heads_for_s2_basic(self): from soup_cli.utils.longlora import shift_heads_for_s2 # [B=1, H=4, T=8, D=2] t = torch.arange(64, dtype=torch.float32).reshape(1, 4, 8, 2) shifted = shift_heads_for_s2(t, group_size=4) assert shifted.shape == t.shape # First half unchanged assert torch.equal(shifted[:, :2, :, :], t[:, :2, :, :]) # Second half shifted by group_size//2 = 2 assert not torch.equal(shifted[:, 2:, :, :], t[:, 2:, :, :]) def test_shift_rejects_bool_group_size(self): from soup_cli.utils.longlora import shift_heads_for_s2 t = torch.zeros(1, 4, 8, 2) with pytest.raises(TypeError, match="bool"): shift_heads_for_s2(t, group_size=True) def test_shift_rejects_small_group_size(self): from soup_cli.utils.longlora import shift_heads_for_s2 t = torch.zeros(1, 4, 8, 2) with pytest.raises(ValueError, match=">= 2"): shift_heads_for_s2(t, group_size=1) def test_shift_rejects_non_4d(self): from soup_cli.utils.longlora import shift_heads_for_s2 t = torch.zeros(1, 4, 8) with pytest.raises(ValueError, match="4-D"): shift_heads_for_s2(t, group_size=4) def test_shift_single_head_no_change(self): from soup_cli.utils.longlora import shift_heads_for_s2 t = torch.arange(16, dtype=torch.float32).reshape(1, 1, 8, 2) out = shift_heads_for_s2(t, group_size=4) assert torch.equal(out, t) def test_apply_longlora_forward_override_returns_context(self): from soup_cli.utils.longlora import ( LongLoRAForwardOverride, apply_longlora_forward_override, ) # Minimal model stub with .modules() iterator. class _Stub: def modules(self): return iter([]) result = apply_longlora_forward_override(_Stub(), group_size=4) assert isinstance(result, LongLoRAForwardOverride) # Restoration is no-op on empty model. with result: pass def test_override_restore_on_exit(self): from soup_cli.utils.longlora import apply_longlora_forward_override class LlamaAttention: # exact name the regex matches def __init__(self): self.marker = "ORIGINAL" def forward(self, x): return (x,) class _Model: def __init__(self): self.attn = LlamaAttention() def modules(self): yield self.attn model = _Model() original_forward = model.attn.forward # Capture marker function attribute instead of bound-method identity # (bound methods compare unequal across calls). override = apply_longlora_forward_override(model, group_size=4) with override: # Patched forward should be a different callable. assert getattr(model.attn.forward, "__name__", None) == "s2_forward" # After exit, the patched closure is gone and the bound method is # back. We check by calling it and verifying we get the (x,) tuple. result = model.attn.forward("hello") assert result == ("hello",) # And confirm the s2_forward wrapper is no longer present. assert getattr(model.attn.forward, "__name__", None) != "s2_forward" del original_forward # silence linter # --------------------------------------------------------------------------- # #126 — PRMTrainerWrapper + compute_prm_loss kernel # --------------------------------------------------------------------------- class TestPRMKernel: def test_compute_prm_loss_perfect_match(self): from soup_cli.utils.prm import compute_prm_loss preds = torch.tensor([1.0, 2.0, 3.0]) labels = torch.tensor([1.0, 2.0, 3.0]) out = compute_prm_loss(preds, labels) assert float(out) == pytest.approx(0.0) def test_compute_prm_loss_mse(self): from soup_cli.utils.prm import compute_prm_loss preds = torch.tensor([1.0, 2.0]) labels = torch.tensor([0.0, 0.0]) out = compute_prm_loss(preds, labels) assert float(out) == pytest.approx((1.0 + 4.0) / 2) def test_compute_prm_loss_mask(self): from soup_cli.utils.prm import compute_prm_loss preds = torch.tensor([1.0, 2.0, 100.0]) labels = torch.tensor([0.0, 0.0, 0.0]) mask = torch.tensor([1.0, 1.0, 0.0]) out = compute_prm_loss(preds, labels, mask=mask) # Masked → only first two contribute. assert float(out) == pytest.approx((1.0 + 4.0) / 2) def test_compute_prm_loss_shape_mismatch(self): from soup_cli.utils.prm import compute_prm_loss preds = torch.tensor([1.0, 2.0]) labels = torch.tensor([1.0, 2.0, 3.0]) with pytest.raises(ValueError, match="shape mismatch"): compute_prm_loss(preds, labels) def test_build_prm_trainer_returns_wrapper(self): # The factory no longer raises NotImplementedError — it returns a # configured wrapper. The actual trust_remote_code probe inside # the wrapper will read None for a stub base, so we use a string. from soup_cli.trainer.prm import PRMTrainerWrapper from soup_cli.utils.prm import build_prm_trainer class _Cfg: base = "hf-internal-testing/tiny-random-gpt2" task = "prm" wrapper = build_prm_trainer(config=_Cfg()) assert isinstance(wrapper, PRMTrainerWrapper) def test_build_prm_trainer_rejects_unknown_kwarg(self): from soup_cli.utils.prm import build_prm_trainer class _Cfg: base = "test" with pytest.raises(TypeError, match="unexpected kwargs"): build_prm_trainer(config=_Cfg(), bogus_kwarg=1) # --------------------------------------------------------------------------- # #127 — GRPO stability callback # --------------------------------------------------------------------------- class TestGRPOStabilityCallback: def test_update_ema(self): from soup_cli.monitoring.grpo_stability_callback import update_ema ref = {"w": torch.zeros(2)} pol = {"w": torch.ones(2)} out = update_ema(ref, pol, alpha=0.5) assert torch.equal(out["w"], torch.full((2,), 0.5)) def test_update_ema_bool_rejected(self): from soup_cli.monitoring.grpo_stability_callback import update_ema with pytest.raises(TypeError, match="bool"): update_ema({}, {}, alpha=True) def test_update_ema_alpha_bounds(self): from soup_cli.monitoring.grpo_stability_callback import update_ema with pytest.raises(ValueError, match="\\(0, 1\\]"): update_ema({}, {}, alpha=0.0) with pytest.raises(ValueError, match="\\(0, 1\\]"): update_ema({}, {}, alpha=1.5) def test_check_tis_threshold(self): from soup_cli.monitoring.grpo_stability_callback import check_tis_threshold log_ratio = torch.tensor([0.1, 0.2, 0.5]) assert check_tis_threshold(log_ratio, 0.3) is True assert check_tis_threshold(log_ratio, 1.0) is False def test_filter_zero_advantage(self): from soup_cli.monitoring.grpo_stability_callback import filter_zero_advantage adv = torch.tensor([0.0, 1.0, -1.0, 1e-10]) mask = filter_zero_advantage(adv) assert mask[0].item() is False or mask[0].item() == 0 assert bool(mask[1]) is True assert bool(mask[2]) is True assert bool(mask[3]) is False def test_callback_construct_with_all_fields(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback cb = GRPOStabilityCallback( ref_model_ema_alpha=0.99, replay_buffer_size=10, async_grpo_prefetch=True, tis_threshold=2.0, mask_truncated_completions=True, defer_rerolling=True, skip_zero_advantage=True, off_policy_mask_threshold=0.5, ) assert cb.ref_model_ema_alpha == 0.99 assert cb.replay_size() == 0 assert cb.tis_alerts() == 0 def test_callback_replay_buffer(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback cb = GRPOStabilityCallback(replay_buffer_size=2) cb.push_rollout("a") cb.push_rollout("b") cb.push_rollout("c") assert cb.replay_size() == 2 # bounded def test_callback_tis_alert(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback cb = GRPOStabilityCallback(tis_threshold=0.3) cb.record_tis_alert(torch.tensor([0.1, 0.2])) # below threshold assert cb.tis_alerts() == 0 cb.record_tis_alert(torch.tensor([0.5, 0.6])) # above threshold assert cb.tis_alerts() == 1 def test_callback_bool_rejected_on_numeric_fields(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback with pytest.raises(TypeError, match="bool"): GRPOStabilityCallback(ref_model_ema_alpha=True) with pytest.raises(TypeError, match="bool"): GRPOStabilityCallback(replay_buffer_size=True) class TestAttachGRPOStabilityCallback: def test_no_field_set_returns_false(self): from soup_cli.utils.peft_wiring import attach_grpo_stability_callback class _TCfg: ref_model_ema_alpha = None replay_buffer_size = None async_grpo_prefetch = False tis_threshold = None mask_truncated_completions = False defer_rerolling = False skip_zero_advantage = False off_policy_mask_threshold = None class _Trainer: def add_callback(self, cb): pass assert attach_grpo_stability_callback(_Trainer(), _TCfg()) is False def test_attaches_when_set(self): from soup_cli.utils.peft_wiring import attach_grpo_stability_callback class _TCfg: ref_model_ema_alpha = 0.99 replay_buffer_size = None async_grpo_prefetch = False tis_threshold = None mask_truncated_completions = False defer_rerolling = False skip_zero_advantage = False off_policy_mask_threshold = None added = [] class _Trainer: def add_callback(self, cb): added.append(cb) assert attach_grpo_stability_callback(_Trainer(), _TCfg()) is True assert len(added) == 1 # --------------------------------------------------------------------------- # #68 — weighted preference combine live wrapper # --------------------------------------------------------------------------- class TestWeightedCombineHook: def test_attaches_to_trainer_with_compute_loss(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _Trainer: def compute_loss(self, model, inputs, return_outputs=False): return torch.tensor(2.0) trainer = _Trainer() ok = attach_weighted_preference_combine( trainer, {"dpo": 0.5, "simpo": 0.5} ) assert ok is True # Re-attach is idempotent. assert attach_weighted_preference_combine( trainer, {"dpo": 0.5, "simpo": 0.5} ) is True def test_returns_false_without_compute_loss(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _NoComputeLoss: pass assert attach_weighted_preference_combine( _NoComputeLoss(), {"dpo": 1.0} ) is False def test_rejects_bco_mixed_with_paired(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _Trainer: def compute_loss(self, *args, **kwargs): return torch.tensor(1.0) with pytest.raises(ValueError, match="bco"): attach_weighted_preference_combine( _Trainer(), {"bco": 0.5, "dpo": 0.5} ) def test_blended_loss_scales_with_weight(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _Trainer: def compute_loss(self, model, inputs, return_outputs=False): return torch.tensor(2.0) trainer = _Trainer() attach_weighted_preference_combine( trainer, {"dpo": 0.7, "simpo": 0.3} ) # Primary is dpo (highest weight). Loss = 2.0 * 0.7 = 1.4 out = trainer.compute_loss(None, None) assert float(out) == pytest.approx(1.4) # --------------------------------------------------------------------------- # Version bump sanity # --------------------------------------------------------------------------- def test_version_bump(): import soup_cli major, minor, patch = (int(x) for x in soup_cli.__version__.split(".")) assert (major, minor, patch) >= (0, 53, 11) # --------------------------------------------------------------------------- # v0.53.11 #123 — _GRPOTrainerVariant subclass factory # --------------------------------------------------------------------------- class TestGRPOTrainerVariantFactory: def test_factory_returns_subclass(self): from soup_cli.trainer.grpo import make_grpo_trainer_variant class _FakeGRPOTrainer: def compute_loss(self, model, inputs, return_outputs=False, **kwargs): return torch.tensor(1.0) variant_cls = make_grpo_trainer_variant(_FakeGRPOTrainer, "gspo") assert issubclass(variant_cls, _FakeGRPOTrainer) assert variant_cls._soup_grpo_variant == "gspo" def test_factory_cached_by_variant(self): from soup_cli.trainer.grpo import make_grpo_trainer_variant class _FakeGRPOTrainer: def compute_loss(self, *a, **k): return torch.tensor(0.0) a = make_grpo_trainer_variant(_FakeGRPOTrainer, "gspo") b = make_grpo_trainer_variant(_FakeGRPOTrainer, "gspo") assert a is b def test_variant_compute_loss_routes_through_kernel(self): from soup_cli.trainer.grpo import make_grpo_trainer_variant class _FakeGRPOTrainer: def __init__(self): self.args = type("Args", (), {"beta": 0.1})() def compute_loss(self, model, inputs, return_outputs=False, **kwargs): return torch.tensor(99.0) variant_cls = make_grpo_trainer_variant(_FakeGRPOTrainer, "gspo") trainer = variant_cls() inputs = { "per_token_logps": torch.zeros(2, 4), "old_per_token_logps": torch.zeros(2, 4), "advantages": torch.tensor([1.0, -1.0]), } # Variant kernel should compute a real (non-99.0) loss. result = trainer.compute_loss(model=None, inputs=inputs) assert isinstance(result, torch.Tensor) def test_variant_falls_back_on_missing_inputs(self): """When TRL renames inputs, fall back to original loss (defence-in-depth).""" from soup_cli.trainer.grpo import make_grpo_trainer_variant class _FakeGRPOTrainer: def __init__(self): self.args = type("Args", (), {"beta": 0.1})() def compute_loss(self, model, inputs, return_outputs=False, **kwargs): return torch.tensor(42.0) variant_cls = make_grpo_trainer_variant(_FakeGRPOTrainer, "gspo") trainer = variant_cls() # No expected keys — falls back to original. result = trainer.compute_loss(model=None, inputs={}) assert float(result) == 42.0 # --------------------------------------------------------------------------- # v0.53.11 #126 — PRM Trainer subclass factory + dataset prep # --------------------------------------------------------------------------- class TestPRMTrainerSubclass: def test_factory_returns_subclass(self): from soup_cli.trainer.prm import make_prm_trainer_class class _FakeTrainer: pass prm_cls = make_prm_trainer_class(_FakeTrainer) assert issubclass(prm_cls, _FakeTrainer) assert hasattr(prm_cls, "compute_loss") def test_prepare_dataset_skips_mismatched(self): from soup_cli.trainer.prm import _prepare_prm_dataset class _Tok: def __call__(self, text, add_special_tokens=False): return {"input_ids": list(range(len(text.split())))} rows = [ {"prompt": "hi", "completions": ["a", "b"], "labels": [1.0]}, # mismatch {"prompt": "hi", "completions": ["a"], "labels": [0.5]}, # ok {}, # missing fields ] prepared = _prepare_prm_dataset(rows, _Tok(), max_length=100) assert len(prepared) == 1 assert prepared[0]["step_positions"] == [1] assert prepared[0]["labels"] == [0.5] def test_prepare_dataset_truncates_long(self): from soup_cli.trainer.prm import _prepare_prm_dataset class _Tok: def __call__(self, text, add_special_tokens=False): return {"input_ids": [1] * 50} rows = [{"prompt": "p", "completions": ["a", "b", "c"], "labels": [1, 2, 3]}] prepared = _prepare_prm_dataset(rows, _Tok(), max_length=80) # Should truncate to fit max_length. assert len(prepared) == 1 assert all(p < 80 for p in prepared[0]["step_positions"]) # --------------------------------------------------------------------------- # v0.53.11 #68 — true weighted combine path # --------------------------------------------------------------------------- class TestTrueWeightedCombine: def test_uses_combine_when_logps_available(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _Trainer: beta = 0.1 simpo_gamma = 1.0 def compute_loss(self, model, inputs, return_outputs=False): # Returns primary loss; should be REPLACED by weighted combine. return torch.tensor(99.0) trainer = _Trainer() attach_weighted_preference_combine( trainer, {"dpo": 0.5, "simpo": 0.5} ) # Provide per-batch logps so the true-weighted path activates. inputs = { "policy_chosen_logps": torch.tensor([0.0, 0.1]), "policy_rejected_logps": torch.tensor([-0.5, -0.6]), "reference_chosen_logps": torch.tensor([0.0, 0.0]), "reference_rejected_logps": torch.tensor([0.0, 0.0]), } out = trainer.compute_loss(model=None, inputs=inputs) # Result is the TRUE weighted combine, not the 99.0 placeholder. assert float(out) != 99.0 assert math.isfinite(float(out)) def test_falls_back_when_logps_missing(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _Trainer: def compute_loss(self, model, inputs, return_outputs=False): return torch.tensor(2.0) trainer = _Trainer() attach_weighted_preference_combine( trainer, {"dpo": 0.7, "simpo": 0.3} ) # No logps → fallback to primary scaling. out = trainer.compute_loss(model=None, inputs={}) assert float(out) == pytest.approx(2.0 * 0.7) # --------------------------------------------------------------------------- # v0.53.11 #127 — live EMA hook in callback # --------------------------------------------------------------------------- class TestStabilityCallbackEMA: def test_on_step_end_runs_ema_update(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback class _Model: def __init__(self, val): self._val = val def state_dict(self): return {"w": torch.full((2,), self._val)} def load_state_dict(self, sd, strict=True): self._loaded = sd cb = GRPOStabilityCallback(ref_model_ema_alpha=0.5) ref = _Model(0.0) pol = _Model(1.0) cb._policy_model = pol cb._ref_model = ref class _State: log_history: list = [] state = _State() cb.on_step_end(args=None, state=state, control=None, model=pol) # Loaded back into ref: midpoint 0.5. assert torch.allclose(ref._loaded["w"], torch.full((2,), 0.5)) def test_on_step_end_logs_counters(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback cb = GRPOStabilityCallback(replay_buffer_size=4, tis_threshold=1.0) cb.push_rollout("a") cb.record_tis_alert(torch.tensor([2.0])) class _State: log_history: list = [] state = _State() cb.on_step_end(args=None, state=state, control=None) assert state.log_history last = state.log_history[-1] assert last["tis_alerts"] == 1 assert last["replay_size"] == 1 def test_on_train_begin_captures_models(self): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback cb = GRPOStabilityCallback(ref_model_ema_alpha=0.99) policy = object() ref = object() class _Trainer: ref_model = ref cb.on_train_begin( args=None, state=None, control=None, model=policy, trainer=_Trainer() ) assert cb._policy_model is policy assert cb._ref_model is ref # --------------------------------------------------------------------------- # v0.53.11 review-fix coverage gaps (tdd-guide findings) # --------------------------------------------------------------------------- class TestReviewFixCoverage: """Tests added in response to v0.53.11 tdd-guide HIGH/MEDIUM findings.""" # --- HIGH: bool / bounds rejection on remaining numeric fields --- @pytest.mark.parametrize("field", [ "ref_model_ema_alpha", "replay_buffer_size", "tis_threshold", "off_policy_mask_threshold", ]) def test_callback_bool_rejected_all_numeric_fields(self, field): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback with pytest.raises(TypeError, match="bool"): GRPOStabilityCallback(**{field: True}) @pytest.mark.parametrize("value", [0.0, 100.5]) def test_callback_tis_threshold_bounds(self, value): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback with pytest.raises(ValueError, match="tis_threshold"): GRPOStabilityCallback(tis_threshold=value) @pytest.mark.parametrize("value", [-0.1, 1.1]) def test_callback_off_policy_bounds(self, value): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback with pytest.raises(ValueError, match="off_policy_mask_threshold"): GRPOStabilityCallback(off_policy_mask_threshold=value) @pytest.mark.parametrize("value", [0, 1_000_001]) def test_callback_replay_buffer_size_bounds(self, value): from soup_cli.monitoring.grpo_stability_callback import GRPOStabilityCallback with pytest.raises(ValueError, match="replay_buffer_size"): GRPOStabilityCallback(replay_buffer_size=value) def test_check_tis_threshold_bool_rejected(self): from soup_cli.monitoring.grpo_stability_callback import check_tis_threshold with pytest.raises(TypeError, match="bool"): check_tis_threshold(torch.tensor([1.0]), True) def test_update_ema_nan_rejected(self): from soup_cli.monitoring.grpo_stability_callback import update_ema with pytest.raises(ValueError, match="finite"): update_ema({}, {}, alpha=float("nan")) # --- HIGH: variant compute_loss return_outputs=True path --- def test_variant_compute_loss_return_outputs_true(self): from soup_cli.trainer.grpo import make_grpo_trainer_variant class _FakeGRPOTrainer: def __init__(self): self.args = type("Args", (), {"beta": 0.1})() def compute_loss(self, model, inputs, return_outputs=False, **kwargs): return (torch.tensor(99.0), {"fake": True}) variant_cls = make_grpo_trainer_variant(_FakeGRPOTrainer, "gspo") trainer = variant_cls() inputs = { "per_token_logps": torch.zeros(2, 4), "old_per_token_logps": torch.zeros(2, 4), "advantages": torch.tensor([1.0, -1.0]), } result = trainer.compute_loss( model=None, inputs=inputs, return_outputs=True ) # When kernel handles the loss we return (variant_loss, None) per # the implementation — outputs are not reproducible without a full # forward pass. assert isinstance(result, tuple) assert len(result) == 2 loss, _outputs = result assert isinstance(loss, torch.Tensor) # --- HIGH: source-grep regression — callback wired into grpo.py --- def test_grpo_trainer_wires_stability_callback_source_grep(self): import inspect import soup_cli.trainer.grpo as grpo_mod body = inspect.getsource(grpo_mod) assert "attach_grpo_stability_callback" in body, ( "GRPOTrainerWrapper.setup must call attach_grpo_stability_callback" ) def test_preference_wires_weighted_combine_source_grep(self): import inspect import soup_cli.trainer.preference as pref_mod body = inspect.getsource(pref_mod) assert "attach_weighted_preference_combine" in body, ( "PreferenceTrainerWrapper.setup must wire attach_weighted_preference_combine" ) # --- MEDIUM: idempotent attach + restore-on-exception --- def test_attach_weighted_preference_not_double_wrapped(self): from soup_cli.utils.preference_combine import ( attach_weighted_preference_combine, ) class _Trainer: def compute_loss(self, model, inputs, return_outputs=False): return torch.tensor(2.0) trainer = _Trainer() attach_weighted_preference_combine(trainer, {"dpo": 0.6, "simpo": 0.4}) first_wrapper = trainer.compute_loss # Second attach must short-circuit; compute_loss unchanged. attach_weighted_preference_combine(trainer, {"dpo": 0.6, "simpo": 0.4}) assert trainer.compute_loss is first_wrapper def test_longlora_idempotent_install(self): from soup_cli.utils.longlora import apply_longlora_forward_override class LlamaAttention: def forward(self, x): return (x,) class _Model: def __init__(self): self.attn = LlamaAttention() def modules(self): yield self.attn model = _Model() ovr1 = apply_longlora_forward_override(model, group_size=4) with ovr1: wrapped_once = model.attn.forward # Nested context on the same module must NOT double-wrap. ovr2 = apply_longlora_forward_override(model, group_size=4) with ovr2: assert model.attn.forward is wrapped_once # After exiting, original forward restored. assert not getattr(model.attn.forward, "_soup_longlora_patched", False) def test_longlora_restores_on_exception(self): from soup_cli.utils.longlora import apply_longlora_forward_override class LlamaAttention: def forward(self, x): return (x,) class _Model: def __init__(self): self.attn = LlamaAttention() def modules(self): yield self.attn model = _Model() original = model.attn.forward ovr = apply_longlora_forward_override(model, group_size=4) with pytest.raises(RuntimeError): with ovr: raise RuntimeError("simulated mid-training crash") # Forward restored despite the exception. assert not getattr(model.attn.forward, "_soup_longlora_patched", False) _ = original # silence lint # --- MEDIUM: factory cache isolation between base classes --- def test_variant_factory_isolates_by_base_class(self): from soup_cli.trainer.grpo import make_grpo_trainer_variant class _BaseA: def compute_loss(self, *a, **k): return torch.tensor(0.0) class _BaseB: def compute_loss(self, *a, **k): return torch.tensor(0.0) a = make_grpo_trainer_variant(_BaseA, "gspo") b = make_grpo_trainer_variant(_BaseB, "gspo") assert a is not b def test_variant_factory_normalises_case(self): """v0.53.11 review fix (security MEDIUM) — case-insensitive cache.""" from soup_cli.trainer.grpo import make_grpo_trainer_variant class _Base: def compute_loss(self, *a, **k): return torch.tensor(0.0) a = make_grpo_trainer_variant(_Base, "gspo") b = make_grpo_trainer_variant(_Base, "GSPO") assert a is b # --- LOW: empty completions in PRM dataset prep --- def test_prepare_prm_empty_completions(self): from soup_cli.trainer.prm import _prepare_prm_dataset class _Tok: def __call__(self, text, add_special_tokens=False): return {"input_ids": [1, 2, 3]} rows = [{"prompt": "p", "completions": [], "labels": []}] prepared = _prepare_prm_dataset(rows, _Tok(), max_length=100) # Empty completions → zero output rows (does not crash). assert prepared == []