DeepInfer/tests/test_sampling_params.py
akash6murali 2afdb3d2d8 test: add unit tests for SamplingParams, CacheConfig, ParallelConfig, SchedulerConfig
The config and sampling-parameter classes had zero test coverage despite
containing non-trivial validation logic that is exercised on every engine
startup.  This PR adds two focused, CPU-only test files that cover all
_verify_args paths and several construction edge cases.

tests/test_sampling_params.py (47 tests)
  Covers every validation rule in SamplingParams._verify_args:
  - temperature: non-negative check, greedy-mode clamp (< SAMPLING_EPS
    → temperature=SAMPLING_EPS, top_p=1.0, top_k=-1).
  - top_p: exclusive lower bound (> 0.0) and upper bound (<= 1.0).
  - top_k: -1 (disabled) or [1, INT32_MAX]; zero and other negatives rejected.
  - max_tokens: >= 1 and <= INT32_MAX; notably max_tokens=0 now raises
    ValueError (the original code silently coerced 0 → 1 before validation).
  - top_logprobs: [0, 1000].
  - seq_seed: auto-generation, explicit value, uint64 masking, non-int raises.
  - logprobs deprecation: logprobs=False emits no warning; logprobs=True
    emits DeprecationWarning matching "logprobs.*deprecated".
  - Optional fields: golden_token_ids, so_args, ignore_eos, first_n_token_no_eos,
    eos_id, prompt_logprobs.
  - Greedy mode: temperature=0 overrides top_k and top_p; non-zero preserves them.

tests/test_config.py (23 tests)
  CacheConfig:
  - Valid construction, boundary values (utilization=1.0), lookahead_tokens,
    deferred-field initialisation (num_gpu_blocks etc. start as None).
  - Rejects gpu_memory_utilization > 1.0 (existing check).
  - Two tests marked xfail(strict=True) for the missing lower-bound guard
    (0.0 and negative values currently pass silently).  These will flip to
    XPASS once PR fix/cache-config-validation is merged.

  ParallelConfig:
  - Auto-derivation of data_parallel_size when 0 is passed (min 1).
  - world_size, data_parallel_rank, model_parallel_size, engram derivation.
  - AssertionError when (cp * dp) is not divisible by ep.
  - AssertionError when engine_rank is out of range.

  SchedulerConfig:
  - max_num_batched_tokens auto-default (>= max(max_model_len, 4096)).
  - ValueError when explicit max_num_batched_tokens < max_model_len and
    chunked_prefill=False; passes when chunked_prefill=True.
  - ValueError for prefill_attempt_interval <= 0 and
    prefill_any_attempt_interval <= 0.
  - runtime_max_model_len >= max_model_len.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-20 14:28:11 -04:00

324 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Unit tests for SamplingParams validation and construction.
These tests exercise every validation rule in SamplingParams._verify_args(),
the greedy-mode clamping applied after validation, the _make_seed helper,
and the deprecation warning for the `logprobs` parameter.
All tests are CPU-only and require no model weights or CUDA device.
"""
from __future__ import annotations
import random
import warnings
import pytest
import torch
from deepinfer.sampling_params import SAMPLING_EPS, SamplingParams
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make(**kwargs) -> SamplingParams:
"""Construct SamplingParams with defaults overridden by kwargs."""
return SamplingParams(**kwargs)
# ---------------------------------------------------------------------------
# Default construction
# ---------------------------------------------------------------------------
class TestDefaults:
def test_default_construction_succeeds(self) -> None:
sp = SamplingParams()
assert sp.temperature == pytest.approx(1.0)
assert sp.top_p == pytest.approx(1.0)
assert sp.top_k == -1
assert sp.ignore_eos is False
assert sp.max_tokens == 16
assert sp.prompt_logprobs is False
assert sp.top_logprobs == 0
assert sp.so_args is None
assert sp.golden_token_ids is None
def test_seq_seed_auto_generated(self) -> None:
sp1 = SamplingParams()
sp2 = SamplingParams()
# With overwhelming probability two auto-seeds differ.
# If they collide the test is still valid (just flaky by chance).
assert 0 <= sp1.seq_seed < 2**64
assert 0 <= sp2.seq_seed < 2**64
def test_repr_contains_key_fields(self) -> None:
sp = SamplingParams(temperature=0.8, max_tokens=64)
r = repr(sp)
assert "temperature=0.8" in r
assert "max_tokens=64" in r
assert "top_p=" in r
assert "seq_seed=" in r
# ---------------------------------------------------------------------------
# temperature
# ---------------------------------------------------------------------------
class TestTemperature:
def test_valid_positive(self) -> None:
make(temperature=0.7)
def test_valid_exactly_one(self) -> None:
make(temperature=1.0)
def test_valid_high(self) -> None:
make(temperature=2.5)
def test_zero_triggers_greedy_clamp(self) -> None:
"""temperature=0 should not raise; instead it is clamped to SAMPLING_EPS
and top_p/top_k are reset to greedy defaults."""
sp = make(temperature=0.0, top_p=0.9, top_k=50)
assert sp.temperature == pytest.approx(SAMPLING_EPS)
assert sp.top_p == pytest.approx(1.0)
assert sp.top_k == -1
def test_near_zero_below_eps_triggers_greedy_clamp(self) -> None:
sp = make(temperature=SAMPLING_EPS / 2)
assert sp.temperature == pytest.approx(SAMPLING_EPS)
def test_exactly_eps_is_not_clamped(self) -> None:
sp = make(temperature=SAMPLING_EPS)
# SAMPLING_EPS is NOT strictly less than SAMPLING_EPS, so no clamp.
assert sp.temperature == pytest.approx(SAMPLING_EPS)
def test_negative_raises(self) -> None:
with pytest.raises(ValueError, match="temperature must be non-negative"):
make(temperature=-0.1)
# ---------------------------------------------------------------------------
# top_p
# ---------------------------------------------------------------------------
class TestTopP:
def test_valid_one(self) -> None:
make(top_p=1.0)
def test_valid_fraction(self) -> None:
make(top_p=0.9)
def test_valid_near_zero(self) -> None:
make(top_p=1e-9)
def test_zero_raises(self) -> None:
with pytest.raises(ValueError, match="top_p must be in"):
make(top_p=0.0)
def test_above_one_raises(self) -> None:
with pytest.raises(ValueError, match="top_p must be in"):
make(top_p=1.01)
def test_negative_raises(self) -> None:
with pytest.raises(ValueError, match="top_p must be in"):
make(top_p=-0.5)
# ---------------------------------------------------------------------------
# top_k
# ---------------------------------------------------------------------------
class TestTopK:
def test_disabled(self) -> None:
make(top_k=-1)
def test_valid_one(self) -> None:
make(top_k=1)
def test_valid_large(self) -> None:
make(top_k=torch.iinfo(torch.int32).max)
def test_zero_raises(self) -> None:
with pytest.raises(ValueError, match="top_k must be -1"):
make(top_k=0)
def test_negative_other_than_minus_one_raises(self) -> None:
with pytest.raises(ValueError, match="top_k must be -1"):
make(top_k=-2)
def test_above_int32_max_raises(self) -> None:
with pytest.raises(ValueError, match="top_k must be -1"):
make(top_k=torch.iinfo(torch.int32).max + 1)
# ---------------------------------------------------------------------------
# max_tokens
# ---------------------------------------------------------------------------
class TestMaxTokens:
def test_valid_minimum(self) -> None:
sp = make(max_tokens=1)
assert sp.max_tokens == 1
def test_valid_default(self) -> None:
sp = make()
assert sp.max_tokens == 16
def test_valid_large(self) -> None:
make(max_tokens=131072)
def test_zero_raises(self) -> None:
"""max_tokens=0 must raise ValueError rather than being silently coerced to 1."""
with pytest.raises(ValueError, match="max_tokens must be >= 1"):
make(max_tokens=0)
def test_negative_raises(self) -> None:
with pytest.raises(ValueError, match="max_tokens must be >= 1"):
make(max_tokens=-1)
def test_above_int32_max_raises(self) -> None:
with pytest.raises(ValueError, match="max_tokens must be >= 1"):
make(max_tokens=torch.iinfo(torch.int32).max + 1)
# ---------------------------------------------------------------------------
# top_logprobs
# ---------------------------------------------------------------------------
class TestTopLogprobs:
def test_valid_zero(self) -> None:
make(top_logprobs=0)
def test_valid_boundary(self) -> None:
make(top_logprobs=1000)
def test_negative_raises(self) -> None:
with pytest.raises(ValueError, match="top_logprobs must be in"):
make(top_logprobs=-1)
def test_above_1000_raises(self) -> None:
with pytest.raises(ValueError, match="top_logprobs must be in"):
make(top_logprobs=1001)
# ---------------------------------------------------------------------------
# seq_seed
# ---------------------------------------------------------------------------
class TestSeqSeed:
def test_none_generates_random_seed(self) -> None:
sp = make(seq_seed=None)
assert 0 <= sp.seq_seed < 2**64
def test_explicit_seed_stored(self) -> None:
sp = make(seq_seed=42)
assert sp.seq_seed == 42
def test_seed_clamped_to_uint64(self) -> None:
# A value larger than uint64 is masked to the lower 64 bits.
big = (2**64) + 5
sp = make(seq_seed=big)
assert sp.seq_seed == 5
def test_zero_seed(self) -> None:
sp = make(seq_seed=0)
assert sp.seq_seed == 0
def test_max_uint64_seed(self) -> None:
max_seed = 2**64 - 1
sp = make(seq_seed=max_seed)
assert sp.seq_seed == max_seed
def test_non_int_seed_raises(self) -> None:
with pytest.raises(TypeError):
make(seq_seed="not-an-int") # type: ignore[arg-type]
def test_make_seed_deterministic_for_same_value(self) -> None:
assert SamplingParams._make_seed(123) == SamplingParams._make_seed(123)
def test_make_seed_none_is_random(self) -> None:
random.seed(0)
s1 = SamplingParams._make_seed(None)
random.seed(0)
s2 = SamplingParams._make_seed(None)
# Same random state → same result.
assert s1 == s2
# ---------------------------------------------------------------------------
# logprobs deprecation
# ---------------------------------------------------------------------------
class TestLogprobsDeprecation:
def test_logprobs_false_no_warning(self) -> None:
"""The default logprobs=False must not emit any warning."""
with warnings.catch_warnings():
warnings.simplefilter("error")
make(logprobs=False)
def test_logprobs_true_emits_deprecation_warning(self) -> None:
with pytest.warns(DeprecationWarning, match="logprobs.*deprecated"):
make(logprobs=True)
def test_logprobs_true_does_not_affect_top_logprobs(self) -> None:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
sp = make(logprobs=True, top_logprobs=5)
assert sp.top_logprobs == 5
# ---------------------------------------------------------------------------
# Optional / passthrough fields
# ---------------------------------------------------------------------------
class TestOptionalFields:
def test_golden_token_ids_stored(self) -> None:
ids = [1, 2, 3]
sp = make(golden_token_ids=ids)
assert sp.golden_token_ids == ids
def test_golden_token_ids_none_by_default(self) -> None:
assert make().golden_token_ids is None
def test_so_args_stored(self) -> None:
so = {"schema_type": "json", "schema": "{}"}
sp = make(so_args=so)
assert sp.so_args == so
def test_ignore_eos_stored(self) -> None:
sp = make(ignore_eos=True)
assert sp.ignore_eos is True
def test_first_n_token_no_eos_stored(self) -> None:
sp = make(first_n_token_no_eos=3, eos_id=2)
assert sp.first_n_token_no_eos == 3
assert sp.eos_id == 2
def test_prompt_logprobs_stored(self) -> None:
sp = make(prompt_logprobs=True)
assert sp.prompt_logprobs is True
# ---------------------------------------------------------------------------
# Greedy mode interaction
# ---------------------------------------------------------------------------
class TestGreedyMode:
def test_greedy_overrides_top_k(self) -> None:
sp = make(temperature=0.0, top_k=100)
assert sp.top_k == -1
def test_non_greedy_preserves_top_k(self) -> None:
sp = make(temperature=0.5, top_k=50)
assert sp.top_k == 50
def test_greedy_overrides_top_p(self) -> None:
sp = make(temperature=0.0, top_p=0.95)
assert sp.top_p == pytest.approx(1.0)
def test_non_greedy_preserves_top_p(self) -> None:
sp = make(temperature=0.5, top_p=0.95)
assert sp.top_p == pytest.approx(0.95)