446 lines
19 KiB
Python
446 lines
19 KiB
Python
"""Unit tests for srt/sampling/custom_logit_processor.py — 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 json
|
|
import unittest
|
|
from unittest.mock import MagicMock
|
|
|
|
import torch
|
|
|
|
from sglang.srt.sampling.custom_logit_processor import (
|
|
CustomLogitProcessor,
|
|
DeepseekOCRNoRepeatNGramLogitProcessor,
|
|
DeepSeekR1ThinkingBudgetLogitProcessor,
|
|
DisallowedTokensLogitsProcessor,
|
|
Qwen3ThinkingBudgetLogitProcessor,
|
|
_cache_from_str,
|
|
)
|
|
from sglang.test.test_utils import CustomTestCase
|
|
|
|
|
|
# Helper: mock a Req object (used by ThinkingBudget and NGram processors)
|
|
def _make_req(origin_input_ids=None, output_ids=None):
|
|
req = MagicMock()
|
|
req.origin_input_ids = origin_input_ids or []
|
|
req.output_ids = output_ids or []
|
|
return req
|
|
|
|
|
|
# Serialization round-trip
|
|
class TestCustomLogitProcessorSerialization(CustomTestCase):
|
|
|
|
def test_to_str_produces_valid_json(self):
|
|
"""Test that to_str() produces valid JSON with a 'callable' field."""
|
|
s = DisallowedTokensLogitsProcessor.to_str()
|
|
data = json.loads(s)
|
|
self.assertIn("callable", data)
|
|
self.assertIsInstance(data["callable"], str)
|
|
|
|
def test_round_trip_serialization(self):
|
|
"""Test serialize then deserialize produces a usable processor."""
|
|
s = DisallowedTokensLogitsProcessor.to_str()
|
|
processor = CustomLogitProcessor.from_str(s)
|
|
self.assertIsInstance(processor, DisallowedTokensLogitsProcessor)
|
|
|
|
def test_from_str_is_cached(self):
|
|
"""Test that from_str uses LRU cache for repeated calls."""
|
|
_cache_from_str.cache_clear()
|
|
s = DisallowedTokensLogitsProcessor.to_str()
|
|
cls1 = _cache_from_str(s)
|
|
cls2 = _cache_from_str(s)
|
|
self.assertIs(cls1, cls2)
|
|
|
|
|
|
# DisallowedTokensLogitsProcessor
|
|
class TestDisallowedTokensLogitsProcessor(CustomTestCase):
|
|
def setUp(self):
|
|
self.processor = DisallowedTokensLogitsProcessor()
|
|
|
|
def test_disallowed_tokens_set_to_neg_inf(self):
|
|
"""Test that disallowed token positions are set to -inf for all batch items."""
|
|
logits = torch.zeros(2, 10)
|
|
params = [{"token_ids": [2, 5]}, {"token_ids": [2, 5]}]
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.isinf(result[0, 2]) and result[0, 2] < 0)
|
|
self.assertTrue(torch.isinf(result[0, 5]) and result[0, 5] < 0)
|
|
self.assertTrue(torch.isinf(result[1, 2]) and result[1, 2] < 0)
|
|
|
|
def test_allowed_tokens_unchanged(self):
|
|
"""Test that non-disallowed tokens keep their original logit values."""
|
|
logits = torch.ones(1, 10)
|
|
params = [{"token_ids": [3]}]
|
|
result = self.processor(logits, params)
|
|
self.assertEqual(result[0, 0].item(), 1.0)
|
|
self.assertEqual(result[0, 4].item(), 1.0)
|
|
self.assertTrue(torch.isinf(result[0, 3]) and result[0, 3] < 0)
|
|
|
|
def test_mismatched_params_raises(self):
|
|
"""Test that mismatched token_ids across batch items raises AssertionError."""
|
|
logits = torch.zeros(2, 10)
|
|
params = [{"token_ids": [1, 2]}, {"token_ids": [3, 4]}]
|
|
with self.assertRaises(AssertionError):
|
|
self.processor(logits, params)
|
|
|
|
|
|
# ThinkingBudgetLogitProcessor (using Qwen3 variant)
|
|
class TestThinkingBudgetLogitProcessor(CustomTestCase):
|
|
"""Test thinking budget enforcement using Qwen3 token IDs.
|
|
|
|
Qwen3 tokens:
|
|
THINKING_START = 151667
|
|
THINKING_END = 151668
|
|
NEW_LINE = 198
|
|
"""
|
|
|
|
START = Qwen3ThinkingBudgetLogitProcessor.THINKING_START_TOKEN_ID
|
|
END = Qwen3ThinkingBudgetLogitProcessor.THINKING_END_TOKEN_ID
|
|
NL = Qwen3ThinkingBudgetLogitProcessor.NEW_LINE_TOKEN_ID
|
|
VOCAB = 200000
|
|
|
|
def setUp(self):
|
|
self.processor = Qwen3ThinkingBudgetLogitProcessor()
|
|
|
|
def _logits(self, batch_size=1):
|
|
return torch.zeros(batch_size, self.VOCAB)
|
|
|
|
def test_budget_not_exceeded_no_change(self):
|
|
"""Test no modification when thinking tokens are within budget."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START],
|
|
output_ids=[100, 101], # 2 tokens after start
|
|
)
|
|
params = [{"thinking_budget": 10, "__req__": req}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
self.assertEqual(result[0, 0].item(), 0.0) # unchanged
|
|
|
|
def test_budget_exceeded_forces_newline_first(self):
|
|
"""Test forcing newline when budget exceeded and last token is not newline."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START],
|
|
output_ids=[100] * 5, # 5 tokens, budget=5 → exceeded
|
|
)
|
|
params = [{"thinking_budget": 5, "__req__": req}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# newline should be the only non-neg-inf token
|
|
self.assertEqual(result[0, self.NL].item(), 0.0)
|
|
self.assertTrue(torch.isinf(result[0, 0]) and result[0, 0] < 0)
|
|
|
|
def test_budget_exceeded_with_newline_forces_end_token(self):
|
|
"""Test forcing end token when budget exceeded and last token is newline."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START],
|
|
output_ids=[100] * 5 + [self.NL], # 6 tokens, last is newline
|
|
)
|
|
params = [{"thinking_budget": 5, "__req__": req}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
self.assertEqual(result[0, self.END].item(), 0.0)
|
|
self.assertTrue(torch.isinf(result[0, 0]) and result[0, 0] < 0)
|
|
|
|
def test_skips_when_not_in_thinking(self):
|
|
"""Test skip when THINKING_START is absent (no thinking phase)."""
|
|
req = _make_req(origin_input_ids=[100, 101], output_ids=[102])
|
|
params = [{"thinking_budget": 0, "__req__": req}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_skips_when_thinking_already_ended(self):
|
|
"""Test skip when THINKING_END already appeared."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START],
|
|
output_ids=[100, self.END, 200],
|
|
)
|
|
params = [{"thinking_budget": 0, "__req__": req}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_skips_when_budget_is_none(self):
|
|
"""Test that thinking_budget=None is ignored even during thinking phase."""
|
|
req = _make_req(origin_input_ids=[self.START], output_ids=[100] * 10)
|
|
params = [{"thinking_budget": None, "__req__": req}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_skips_when_budget_is_negative(self):
|
|
"""Test that negative thinking_budget is treated as disabled (no enforcement)."""
|
|
req = _make_req(origin_input_ids=[self.START], output_ids=[100] * 10)
|
|
params = [{"thinking_budget": -1, "__req__": req}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_none_params_returns_unchanged(self):
|
|
"""Test that passing None as param list returns logits unchanged."""
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, None)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_empty_params_returns_unchanged(self):
|
|
"""Test that passing empty param list returns logits unchanged."""
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, [])
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_budget_zero_forces_immediate_end(self):
|
|
"""Test that budget=0 forces thinking to end immediately."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START],
|
|
output_ids=[100], # 1 token after start > budget=0
|
|
)
|
|
params = [{"thinking_budget": 0, "__req__": req}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# Should force newline since last token (100) is not newline
|
|
self.assertEqual(result[0, self.NL].item(), 0.0)
|
|
|
|
def test_none_param_dict_in_list_skipped(self):
|
|
"""Test that None entry in param list is skipped gracefully."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START],
|
|
output_ids=[100] * 10,
|
|
)
|
|
params = [None, {"thinking_budget": 0, "__req__": req}]
|
|
logits = self._logits(batch_size=2)
|
|
result = self.processor(logits, params)
|
|
# Batch 0 (None param) should be unchanged
|
|
self.assertEqual(result[0, 0].item(), 0.0)
|
|
# Batch 1 should have been modified (budget exceeded)
|
|
self.assertEqual(result[1, self.NL].item(), 0.0)
|
|
self.assertTrue(torch.isinf(result[1, 0]) and result[1, 0] < 0)
|
|
|
|
def test_multiple_thinking_start_counts_from_first(self):
|
|
"""Test that budget counts from the first THINKING_START occurrence."""
|
|
req = _make_req(
|
|
origin_input_ids=[self.START, 100, 101],
|
|
output_ids=[self.START, 200, 201], # second START in output
|
|
)
|
|
# cur_ids = [START, 100, 101, START, 200, 201]
|
|
# First START at index 0, tokens_after_start = 5
|
|
# Budget=10 → 5 < 10 → no modification
|
|
params = [{"thinking_budget": 10, "__req__": req}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_deepseek_r1_variant_forces_end(self):
|
|
"""Test DeepSeekR1 variant with its own token IDs."""
|
|
proc = DeepSeekR1ThinkingBudgetLogitProcessor()
|
|
START = proc.THINKING_START_TOKEN_ID # 128798
|
|
NL = proc.NEW_LINE_TOKEN_ID # 201
|
|
VOCAB = 200000
|
|
|
|
req = _make_req(origin_input_ids=[START], output_ids=[100] * 5)
|
|
params = [{"thinking_budget": 5, "__req__": req}]
|
|
logits = torch.zeros(1, VOCAB)
|
|
result = proc(logits, params)
|
|
# Budget exceeded, last token (100) is not newline → force newline
|
|
self.assertEqual(result[0, NL].item(), 0.0)
|
|
self.assertTrue(torch.isinf(result[0, 0]) and result[0, 0] < 0)
|
|
|
|
|
|
# DeepseekOCRNoRepeatNGramLogitProcessor
|
|
class TestDeepseekOCRNoRepeatNGramLogitProcessor(CustomTestCase):
|
|
VOCAB = 100
|
|
|
|
def setUp(self):
|
|
self.processor = DeepseekOCRNoRepeatNGramLogitProcessor()
|
|
|
|
def _logits(self, batch_size=1):
|
|
return torch.zeros(batch_size, self.VOCAB)
|
|
|
|
def test_bans_repeated_bigrams(self):
|
|
"""Test banning token that completes a repeated bigram."""
|
|
req = _make_req(origin_input_ids=[1, 2, 3, 1, 2])
|
|
params = [
|
|
{
|
|
"__req__": req,
|
|
"ngram_size": 2,
|
|
"window_size": 100,
|
|
}
|
|
]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.isinf(result[0, 3]) and result[0, 3] < 0)
|
|
|
|
def test_non_repeated_tokens_unchanged(self):
|
|
"""Test that tokens not completing a repeated ngram are unchanged."""
|
|
req = _make_req(origin_input_ids=[1, 2, 3, 1, 2])
|
|
params = [{"__req__": req, "ngram_size": 2, "window_size": 100}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# Token 1 is not banned (prefix (2) was followed by 3, not 1)
|
|
self.assertEqual(result[0, 1].item(), 0.0)
|
|
|
|
def test_window_size_limits_search(self):
|
|
"""Test that ngrams outside the window are not considered."""
|
|
# Sequence: [1,2,3,...,1,2] but window only covers the last 3 tokens
|
|
req = _make_req(origin_input_ids=[1, 2, 3, 4, 5, 1, 2])
|
|
params = [{"__req__": req, "ngram_size": 2, "window_size": 3}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# Window covers [5, 1, 2]. The bigram (1,2) from index 0-1 is outside.
|
|
# Within window: bigrams are (5,1), (1,2). Current prefix is (2).
|
|
# No bigram starting with prefix (2) in window → nothing banned.
|
|
self.assertEqual(result[0, 3].item(), 0.0)
|
|
|
|
def test_whitelist_protects_tokens(self):
|
|
"""Test that whitelisted tokens are not banned despite repeated ngrams."""
|
|
req = _make_req(origin_input_ids=[1, 2, 3, 1, 2])
|
|
params = [
|
|
{
|
|
"__req__": req,
|
|
"ngram_size": 2,
|
|
"window_size": 100,
|
|
"whitelist_token_ids": [3],
|
|
}
|
|
]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# Token 3 would be banned but is whitelisted
|
|
self.assertEqual(result[0, 3].item(), 0.0)
|
|
|
|
def test_ngram_size_zero_skips(self):
|
|
"""ngram_size=0 is invalid and should be skipped (no modification)."""
|
|
req = _make_req(origin_input_ids=[1, 2, 1, 2])
|
|
params = [{"__req__": req, "ngram_size": 0, "window_size": 100}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_window_size_zero_skips(self):
|
|
"""Test that window_size=0 disables ngram checking (no modification)."""
|
|
req = _make_req(origin_input_ids=[1, 2, 1, 2])
|
|
params = [{"__req__": req, "ngram_size": 2, "window_size": 0}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_empty_params_returns_unchanged(self):
|
|
"""Test that None param list returns logits unchanged (early return)."""
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, None)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_short_sequence_skips(self):
|
|
"""Sequence shorter than ngram_size should be skipped."""
|
|
req = _make_req(origin_input_ids=[1])
|
|
params = [{"__req__": req, "ngram_size": 3, "window_size": 100}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_unigram_mode(self):
|
|
"""ngram_size=1 bans any token already seen in the window."""
|
|
req = _make_req(origin_input_ids=[5, 10, 15])
|
|
params = [{"__req__": req, "ngram_size": 1, "window_size": 100}]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# All tokens in [5, 10, 15] should be banned
|
|
self.assertTrue(torch.isinf(result[0, 5]) and result[0, 5] < 0)
|
|
self.assertTrue(torch.isinf(result[0, 10]) and result[0, 10] < 0)
|
|
self.assertTrue(torch.isinf(result[0, 15]) and result[0, 15] < 0)
|
|
# Other tokens should be fine
|
|
self.assertEqual(result[0, 0].item(), 0.0)
|
|
|
|
def test_none_req_skips(self):
|
|
"""If __req__ is missing, the batch item should be skipped."""
|
|
params = [{"ngram_size": 2, "window_size": 100}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_invalid_ngram_size_type_skips(self):
|
|
"""Non-numeric ngram_size should be handled gracefully."""
|
|
req = _make_req(origin_input_ids=[1, 2, 1, 2])
|
|
params = [{"__req__": req, "ngram_size": "invalid", "window_size": 100}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_falsy_params_in_list_skipped(self):
|
|
"""A falsy entry (None, {}, 0) in param list should be skipped."""
|
|
req = _make_req(origin_input_ids=[1, 2, 1, 2])
|
|
params = [None, {"__req__": req, "ngram_size": 2, "window_size": 100}]
|
|
logits = self._logits(batch_size=2)
|
|
result = self.processor(logits, params)
|
|
# Batch 0 (None) unchanged
|
|
self.assertEqual(result[0, 0].item(), 0.0)
|
|
# Batch 1 has ban applied
|
|
self.assertTrue(torch.isinf(result[1, 1]) and result[1, 1] < 0)
|
|
|
|
def test_search_end_leq_search_start_skips(self):
|
|
"""Test skip when window is too small for the ngram_size."""
|
|
# sequence length=4, ngram_size=3, window_size=2
|
|
# search_start = max(0, 4-2) = 2
|
|
# search_end = 4 - 3 + 1 = 2
|
|
# search_end (2) <= search_start (2) → skip
|
|
req = _make_req(origin_input_ids=[1, 2, 3, 4])
|
|
params = [{"__req__": req, "ngram_size": 3, "window_size": 2}]
|
|
logits = self._logits()
|
|
original = logits.clone()
|
|
result = self.processor(logits, params)
|
|
self.assertTrue(torch.equal(result, original))
|
|
|
|
def test_invalid_whitelist_type_handled(self):
|
|
"""Test graceful handling of non-iterable whitelist_token_ids."""
|
|
req = _make_req(origin_input_ids=[1, 2, 1, 2])
|
|
params = [
|
|
{
|
|
"__req__": req,
|
|
"ngram_size": 2,
|
|
"window_size": 100,
|
|
"whitelist_token_ids": 999, # int, not iterable
|
|
}
|
|
]
|
|
logits = self._logits()
|
|
result = self.processor(logits, params)
|
|
# Should still ban token 1 (whitelist parse fails, falls back to empty set)
|
|
self.assertTrue(torch.isinf(result[0, 1]) and result[0, 1] < 0)
|
|
|
|
def test_batch_processing(self):
|
|
"""Test that multiple batch items are processed independently."""
|
|
req1 = _make_req(
|
|
origin_input_ids=[1, 2, 1, 2]
|
|
) # will ban token 2 (bigram repeat)
|
|
req2 = _make_req(origin_input_ids=[3, 4, 5]) # no repeat
|
|
params = [
|
|
{"__req__": req1, "ngram_size": 2, "window_size": 100},
|
|
{"__req__": req2, "ngram_size": 2, "window_size": 100},
|
|
]
|
|
logits = self._logits(batch_size=2)
|
|
result = self.processor(logits, params)
|
|
# Batch 0: bigram (1,2) appeared, prefix is (2) → ban token that followed (2) = 1
|
|
# Also (2,1) appeared, prefix is (2) → already covered
|
|
# Actually: sequence is [1,2,1,2], prefix is last (ngram_size-1)=1 token = (2)
|
|
# Scanning: index 0: (1,2) prefix=(1); index 1: (2,1) prefix=(2)→bans 1; index 2: (1,2) prefix=(1)
|
|
# So prefix (2) appeared at index 1, followed by token 1. Ban token 1.
|
|
self.assertTrue(torch.isinf(result[0, 1]) and result[0, 1] < 0)
|
|
# Batch 1: prefix is (5), no matching prefix in window → no bans
|
|
self.assertEqual(result[1, 3].item(), 0.0)
|
|
self.assertEqual(result[1, 4].item(), 0.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|