"""Unit tests for srt/sampling/penaltylib/ — no server, no model loading.""" from sglang.test.ci.ci_register import register_cpu_ci register_cpu_ci(est_time=5, suite="stage-a-test-cpu") import unittest from unittest.mock import MagicMock import torch from sglang.srt.sampling.penaltylib.frequency_penalty import ( BatchedFrequencyPenalizer, ) from sglang.srt.sampling.penaltylib.min_new_tokens import ( BatchedMinNewTokensPenalizer, ) from sglang.srt.sampling.penaltylib.orchestrator import ( BatchedPenalizerOrchestrator, ) from sglang.srt.sampling.penaltylib.presence_penalty import ( BatchedPresencePenalizer, ) from sglang.test.test_utils import CustomTestCase VOCAB_SIZE = 32 DEVICE = "cpu" # Helpers: mock Req and ScheduleBatch def _make_req(freq=0.0, presence=0.0, min_tokens=0, stop_ids=None, eos_id=2): """Create a mock request with sampling params.""" req = MagicMock() req.sampling_params.frequency_penalty = freq req.sampling_params.presence_penalty = presence req.sampling_params.min_new_tokens = min_tokens req.sampling_params.stop_token_ids = stop_ids req.tokenizer.additional_stop_token_ids = None req.tokenizer.eos_token_id = eos_id return req def _make_batch(reqs): """Create a mock ScheduleBatch. Note: orchestrator accesses batch.reqs as an attribute (not a method call).""" batch = MagicMock() batch.reqs = reqs batch.device = DEVICE return batch # BatchedPenalizerOrchestrator class TestBatchedPenalizerOrchestrator(CustomTestCase): def test_init_detects_required_penalizers(self): """Test that orchestrator marks is_required=True when any request has nonzero penalty.""" reqs = [_make_req(freq=1.0)] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) self.assertTrue(orch.is_required) def test_init_not_required_when_no_penalties(self): """Test that orchestrator marks is_required=False when all penalties are zero.""" reqs = [_make_req()] # all defaults (0.0) batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) self.assertFalse(orch.is_required) def test_batch_property_via_weakref(self): """Test that batch property returns the original batch via weakref.""" reqs = [_make_req()] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator(VOCAB_SIZE, batch, set()) self.assertIs(orch.batch, batch) def test_batch_setter_none(self): """Test that setting batch to None breaks the weakref cleanly.""" reqs = [_make_req()] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator(VOCAB_SIZE, batch, set()) orch.batch = None self.assertIsNone(orch.batch) def test_batch_setter_new_batch(self): """Test that batch can be reassigned to a different ScheduleBatch.""" reqs = [_make_req()] batch1 = _make_batch(reqs) batch2 = _make_batch(reqs) orch = BatchedPenalizerOrchestrator(VOCAB_SIZE, batch1, set()) orch.batch = batch2 self.assertIs(orch.batch, batch2) def test_context_manager_releases(self): """Test that exiting the context manager releases all penalizers.""" reqs = [_make_req(freq=1.0)] batch = _make_batch(reqs) with BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) as orch: self.assertTrue(orch.is_required) self.assertFalse(orch.is_required) self.assertEqual(len(orch.penalizers), 0) def test_filter_empty_indices_releases(self): """Test that filtering with no indices left fully releases the orchestrator.""" reqs = [_make_req(freq=1.0)] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) orch.filter(torch.tensor([], dtype=torch.long)) self.assertFalse(orch.is_required) def test_filter_not_required_is_noop(self): """Test that filter on a not-required orchestrator does nothing.""" reqs = [_make_req()] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) self.assertFalse(orch.is_required) orch.filter(torch.tensor([0])) # should not raise def test_merge_both_not_required_is_noop(self): """Test that merging two not-required orchestrators stays not-required.""" reqs = [_make_req()] batch = _make_batch(reqs) orch1 = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) orch2 = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) orch1.merge(orch2) # should not raise self.assertFalse(orch1.is_required) # BatchedFrequencyPenalizer class TestBatchedFrequencyPenalizer(CustomTestCase): def _setup(self, freq_values): reqs = [_make_req(freq=f) for f in freq_values] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) pen = orch.penalizers[BatchedFrequencyPenalizer] return orch, pen def test_is_required_with_nonzero_penalty(self): """Test that nonzero frequency_penalty makes the penalizer required.""" _, pen = self._setup([1.5]) self.assertTrue(pen.is_required()) def test_is_not_required_with_zero_penalty(self): """Test that zero frequency_penalty makes the penalizer not required.""" _, pen = self._setup([0.0]) self.assertFalse(pen.is_required()) def test_cumulate_and_apply(self): """Test that cumulating a token applies frequency penalty to its logit.""" orch, pen = self._setup([2.0]) output_ids = torch.tensor([5]) pen.cumulate_output_tokens(output_ids) logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) self.assertAlmostEqual(logits[0, 5].item(), -2.0, places=5) # Other tokens unaffected self.assertAlmostEqual(logits[0, 0].item(), 0.0, places=5) def test_cumulate_twice_doubles_penalty(self): """Test that frequency penalty scales linearly with occurrence count.""" orch, pen = self._setup([1.0]) pen.cumulate_output_tokens(torch.tensor([3])) pen.cumulate_output_tokens(torch.tensor([3])) logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) self.assertAlmostEqual(logits[0, 3].item(), -2.0, places=5) def test_filter_keeps_subset(self): """Test that filter retains only the selected batch indices.""" orch, pen = self._setup([1.0, 2.0]) keep = torch.tensor([1]) pen.filter(keep) self.assertEqual(pen.frequency_penalties.shape[0], 1) self.assertAlmostEqual(pen.frequency_penalties[0, 0].item(), 2.0, places=5) def test_merge_concatenates(self): """Test that merge concatenates penalty tensors from two penalizers.""" _, pen1 = self._setup([1.0]) _, pen2 = self._setup([2.0]) pen1.merge(pen2) self.assertEqual(pen1.frequency_penalties.shape[0], 2) def test_teardown_cleans_attributes(self): """Test that teardown deletes internal tensors and resets prepared state.""" _, pen = self._setup([1.0]) pen.teardown() self.assertFalse(hasattr(pen, "frequency_penalties")) self.assertFalse(hasattr(pen, "cumulated_frequency_penalties")) self.assertFalse(pen.is_prepared()) def test_cumulate_when_not_prepared_is_noop(self): """Test that cumulate before prepare does not crash.""" _, pen = self._setup([0.0]) # pen is not prepared (is_required=False) pen.cumulate_output_tokens(torch.tensor([1])) # should not raise def test_apply_when_not_prepared_is_noop(self): """Test that apply on an unprepared penalizer leaves logits unchanged.""" _, pen = self._setup([0.0]) logits = torch.zeros(1, VOCAB_SIZE) original = logits.clone() pen.apply(logits) self.assertTrue(torch.equal(logits, original)) # BatchedPresencePenalizer class TestBatchedPresencePenalizer(CustomTestCase): def _setup(self, presence_values): reqs = [_make_req(presence=p) for p in presence_values] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedPresencePenalizer} ) pen = orch.penalizers[BatchedPresencePenalizer] return orch, pen def test_is_required_with_nonzero_penalty(self): """Test that nonzero presence_penalty makes the penalizer required.""" _, pen = self._setup([0.5]) self.assertTrue(pen.is_required()) def test_presence_penalty_does_not_scale(self): """Test that presence penalty is flat (same value regardless of count).""" orch, pen = self._setup([1.0]) pen.cumulate_output_tokens(torch.tensor([7])) pen.cumulate_output_tokens(torch.tensor([7])) # same token again logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) # scatter_ overwrites (not adds), so penalty should be 1.0, not 2.0 self.assertAlmostEqual(logits[0, 7].item(), -1.0, places=5) def test_filter_keeps_subset(self): """Test that filter retains the first request's presence penalty.""" orch, pen = self._setup([1.0, 2.0]) keep = torch.tensor([0]) pen.filter(keep) self.assertEqual(pen.presence_penalties.shape[0], 1) self.assertAlmostEqual(pen.presence_penalties[0, 0].item(), 1.0, places=5) def test_merge_concatenates(self): """Test that merge concatenates presence penalty tensors.""" _, pen1 = self._setup([1.0]) _, pen2 = self._setup([2.0]) pen1.merge(pen2) self.assertEqual(pen1.presence_penalties.shape[0], 2) def test_teardown_cleans_attributes(self): """Test that teardown removes the presence_penalties tensor.""" _, pen = self._setup([1.0]) pen.teardown() self.assertFalse(hasattr(pen, "presence_penalties")) # BatchedMinNewTokensPenalizer class TestBatchedMinNewTokensPenalizer(CustomTestCase): def _setup(self, configs): """configs: list of (min_tokens, stop_ids, eos_id).""" reqs = [_make_req(min_tokens=c[0], stop_ids=c[1], eos_id=c[2]) for c in configs] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedMinNewTokensPenalizer} ) pen = orch.penalizers[BatchedMinNewTokensPenalizer] return orch, pen def test_is_required_with_positive_min_tokens(self): """Test that positive min_new_tokens makes the penalizer required.""" _, pen = self._setup([(5, None, 2)]) self.assertTrue(pen.is_required()) def test_is_not_required_with_zero_min_tokens(self): """Test that min_new_tokens=0 makes the penalizer not required.""" _, pen = self._setup([(0, None, 2)]) self.assertFalse(pen.is_required()) def test_blocks_eos_before_min_tokens(self): """Test that EOS token is blocked before min_new_tokens is reached.""" orch, pen = self._setup([(3, None, 2)]) # Before any output: len=0 < min=3 → block EOS (token 2) logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) self.assertTrue(torch.isinf(logits[0, 2]) and logits[0, 2] < 0) # Non-stop tokens should be fine self.assertEqual(logits[0, 0].item(), 0.0) def test_allows_eos_after_min_tokens(self): """Test that EOS is allowed after generating min_new_tokens.""" orch, pen = self._setup([(2, None, 2)]) # Generate 2 tokens pen.cumulate_output_tokens(torch.tensor([10])) pen.cumulate_output_tokens(torch.tensor([11])) # Now len=2 >= min=2 → EOS should NOT be blocked logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) self.assertEqual(logits[0, 2].item(), 0.0) def test_blocks_custom_stop_tokens(self): """Test that custom stop_token_ids are also blocked before min_new_tokens.""" orch, pen = self._setup([(3, {5, 10}, 2)]) logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) # EOS (2), stop token 5, stop token 10 should all be blocked self.assertTrue(torch.isinf(logits[0, 2]) and logits[0, 2] < 0) self.assertTrue(torch.isinf(logits[0, 5]) and logits[0, 5] < 0) self.assertTrue(torch.isinf(logits[0, 10]) and logits[0, 10] < 0) def test_blocks_additional_stop_tokens(self): """Test that tokenizer's additional_stop_token_ids are also blocked.""" req = _make_req(min_tokens=3, stop_ids=None, eos_id=2) req.tokenizer.additional_stop_token_ids = {7, 8} batch = _make_batch([req]) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedMinNewTokensPenalizer} ) pen = orch.penalizers[BatchedMinNewTokensPenalizer] logits = torch.zeros(1, VOCAB_SIZE) pen.apply(logits) # EOS (2) + additional stops (7, 8) should all be blocked for tok in [2, 7, 8]: self.assertTrue( torch.isinf(logits[0, tok]) and logits[0, tok] < 0, f"token {tok} should be blocked before min_new_tokens", ) # Non-stop tokens should be fine self.assertEqual(logits[0, 0].item(), 0.0) def test_filter_keeps_subset(self): """Test that filter keeps the second request (min_tokens=5) and drops the first.""" orch, pen = self._setup([(3, None, 2), (5, None, 2)]) keep = torch.tensor([1]) pen.filter(keep) self.assertEqual(pen.min_new_tokens.shape[0], 1) self.assertEqual(pen.min_new_tokens[0, 0].item(), 5) def test_merge_concatenates(self): """Test that merge combines min_new_tokens tensors from two penalizers.""" _, pen1 = self._setup([(3, None, 2)]) _, pen2 = self._setup([(5, None, 2)]) pen1.merge(pen2) self.assertEqual(pen1.min_new_tokens.shape[0], 2) def test_teardown_cleans_attributes(self): """Test that teardown removes min_new_tokens, stop_token_penalties, and len_output_tokens.""" _, pen = self._setup([(3, None, 2)]) pen.teardown() self.assertFalse(hasattr(pen, "min_new_tokens")) self.assertFalse(hasattr(pen, "stop_token_penalties")) self.assertFalse(hasattr(pen, "len_output_tokens")) # _BatchedPenalizer base class edge cases class TestBatchedPenalizerBase(CustomTestCase): def test_filter_when_not_prepared_is_noop(self): """Test that filter on an unprepared penalizer does not crash.""" reqs = [_make_req()] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) pen = orch.penalizers[BatchedFrequencyPenalizer] # pen is not prepared (frequency_penalty=0 → not required) pen.filter(torch.tensor([0])) # should not raise def test_merge_prepares_both_if_needed(self): """Test that merge prepares unprepared side before concatenating.""" reqs_a = [_make_req(freq=0.0)] # not required reqs_b = [_make_req(freq=1.0)] # required batch_a = _make_batch(reqs_a) batch_b = _make_batch(reqs_b) orch_a = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch_a, {BatchedFrequencyPenalizer} ) orch_b = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch_b, {BatchedFrequencyPenalizer} ) pen_a = orch_a.penalizers[BatchedFrequencyPenalizer] pen_b = orch_b.penalizers[BatchedFrequencyPenalizer] self.assertFalse(pen_a.is_prepared()) self.assertTrue(pen_b.is_prepared()) # Merge should prepare pen_a first pen_a.merge(pen_b) self.assertTrue(pen_a.is_prepared()) self.assertEqual(pen_a.frequency_penalties.shape[0], 2) def test_merge_both_unprepared_is_noop(self): """Test that merging two unprepared penalizers keeps them unprepared.""" reqs = [_make_req()] batch = _make_batch(reqs) orch1 = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) orch2 = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) pen1 = orch1.penalizers[BatchedFrequencyPenalizer] pen2 = orch2.penalizers[BatchedFrequencyPenalizer] pen1.merge(pen2) # both not prepared → noop self.assertFalse(pen1.is_prepared()) def test_prepare_is_idempotent(self): """Test that calling prepare() multiple times does not crash.""" reqs = [_make_req(freq=1.0)] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) pen = orch.penalizers[BatchedFrequencyPenalizer] self.assertTrue(pen.is_prepared()) # Calling prepare again should not crash or reinitialize pen.prepare() self.assertTrue(pen.is_prepared()) # Orchestrator with multiple penalizer types class TestOrchestratorMultiplePenalizers(CustomTestCase): def test_all_three_penalizers(self): """Test orchestrator managing frequency, presence, and min_new_tokens together.""" reqs = [_make_req(freq=1.0, presence=0.5, min_tokens=2, eos_id=2)] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, { BatchedFrequencyPenalizer, BatchedPresencePenalizer, BatchedMinNewTokensPenalizer, }, ) self.assertTrue(orch.is_required) # Cumulate one token output_ids = torch.tensor([5]) orch.cumulate_output_tokens(output_ids) # Apply all penalties logits = torch.zeros(1, VOCAB_SIZE) orch.apply(logits) # Token 5: freq_penalty=1.0 (cumulated once) + pres_penalty=0.5 self.assertAlmostEqual(logits[0, 5].item(), -1.5, places=4) # EOS (token 2): blocked by min_new_tokens (len=1 < min=2) self.assertTrue(torch.isinf(logits[0, 2]) and logits[0, 2] < 0) def test_filter_with_penalizer_no_longer_required(self): """Test that penalizer is torn down when no longer required after filter.""" reqs = [_make_req(freq=0.0), _make_req(freq=1.0)] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) self.assertTrue(orch.is_required) # Keep only the request with freq=0 (index 0) batch.reqs = [reqs[0]] orch.filter(torch.tensor([0])) pen = orch.penalizers[BatchedFrequencyPenalizer] # After filter, only req with freq=0 remains → penalizer not required self.assertFalse(pen.is_required()) def test_filter_keeps_required_penalizer(self): """Test that filter keeps penalizer active when still required.""" reqs = [_make_req(freq=1.0), _make_req(freq=2.0)] batch = _make_batch(reqs) orch = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch, {BatchedFrequencyPenalizer} ) self.assertTrue(orch.is_required) batch.reqs = [reqs[1]] orch.filter(torch.tensor([1])) self.assertTrue(orch.is_required) def test_merge_one_required(self): """Test that merge marks orchestrator as required when one side is.""" reqs_a = [_make_req(freq=0.0)] reqs_b = [_make_req(freq=1.0)] batch_a = _make_batch(reqs_a) batch_b = _make_batch(reqs_b) orch_a = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch_a, {BatchedFrequencyPenalizer} ) orch_b = BatchedPenalizerOrchestrator( VOCAB_SIZE, batch_b, {BatchedFrequencyPenalizer} ) self.assertFalse(orch_a.is_required) self.assertTrue(orch_b.is_required) orch_a.merge(orch_b) self.assertTrue(orch_a.is_required) pen = orch_a.penalizers[BatchedFrequencyPenalizer] self.assertEqual(pen.frequency_penalties.shape[0], 2) if __name__ == "__main__": unittest.main()