Files
2026-07-13 13:35:10 +08:00

607 lines
22 KiB
Python

from unittest.mock import MagicMock, Mock, patch
import pytest
from ragas.dataset_schema import SingleMetricAnnotation
from ragas.losses import MSELoss
try:
import dspy # noqa: F401
DSPY_AVAILABLE = True
except ImportError:
DSPY_AVAILABLE = False
class TestDSPyOptimizer:
@pytest.mark.skipif(DSPY_AVAILABLE, reason="dspy-ai is installed")
def test_import_error_without_dspy(self):
"""Test that DSPyOptimizer raises ImportError when dspy-ai is not installed."""
with pytest.raises(ImportError, match="DSPy optimizer requires dspy-ai"):
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
DSPyOptimizer()
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_initialization_with_default_params(self):
"""Test DSPyOptimizer initialization with default parameters."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
assert optimizer.num_candidates == 10
assert optimizer.max_bootstrapped_demos == 5
assert optimizer.max_labeled_demos == 5
assert optimizer.init_temperature == 1.0
assert optimizer._dspy is not None
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_initialization_with_custom_params(self):
"""Test DSPyOptimizer initialization with custom parameters."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer(
num_candidates=20,
max_bootstrapped_demos=10,
max_labeled_demos=8,
init_temperature=0.5,
)
assert optimizer.num_candidates == 20
assert optimizer.max_bootstrapped_demos == 10
assert optimizer.max_labeled_demos == 8
assert optimizer.init_temperature == 0.5
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_initialization_with_all_params(self):
"""Test DSPyOptimizer initialization with all parameters."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer(
num_candidates=15,
max_bootstrapped_demos=7,
max_labeled_demos=6,
init_temperature=0.8,
auto="heavy",
num_threads=4,
max_errors=5,
seed=42,
verbose=True,
track_stats=False,
log_dir="/tmp/dspy_logs",
metric_threshold=0.9,
)
assert optimizer.num_candidates == 15
assert optimizer.max_bootstrapped_demos == 7
assert optimizer.max_labeled_demos == 6
assert optimizer.init_temperature == 0.8
assert optimizer.auto == "heavy"
assert optimizer.num_threads == 4
assert optimizer.max_errors == 5
assert optimizer.seed == 42
assert optimizer.verbose is True
assert optimizer.track_stats is False
assert optimizer.log_dir == "/tmp/dspy_logs"
assert optimizer.metric_threshold == 0.9
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_negative_num_candidates(self):
"""Test validation for negative num_candidates."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(ValueError, match="num_candidates must be positive"):
DSPyOptimizer(num_candidates=-1)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_negative_max_bootstrapped_demos(self):
"""Test validation for negative max_bootstrapped_demos."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(
ValueError, match="max_bootstrapped_demos must be non-negative"
):
DSPyOptimizer(max_bootstrapped_demos=-1)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_negative_max_labeled_demos(self):
"""Test validation for negative max_labeled_demos."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(ValueError, match="max_labeled_demos must be non-negative"):
DSPyOptimizer(max_labeled_demos=-1)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_zero_init_temperature(self):
"""Test validation for zero init_temperature."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(ValueError, match="init_temperature must be positive"):
DSPyOptimizer(init_temperature=0)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_invalid_auto(self):
"""Test validation for invalid auto parameter."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(ValueError, match="auto must be"):
DSPyOptimizer(auto="invalid")
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_negative_num_threads(self):
"""Test validation for negative num_threads."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(ValueError, match="num_threads must be positive"):
DSPyOptimizer(num_threads=-1)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_negative_max_errors(self):
"""Test validation for negative max_errors."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(ValueError, match="max_errors must be non-negative"):
DSPyOptimizer(max_errors=-1)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_validation_invalid_metric_threshold(self):
"""Test validation for metric_threshold out of range."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
with pytest.raises(
ValueError, match="metric_threshold must be between 0 and 1"
):
DSPyOptimizer(metric_threshold=1.5)
with pytest.raises(
ValueError, match="metric_threshold must be between 0 and 1"
):
DSPyOptimizer(metric_threshold=-0.1)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_optimize_without_metric(self, fake_llm):
"""Test that optimize raises ValueError when no metric is set."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
optimizer.llm = fake_llm
dataset = Mock(spec=SingleMetricAnnotation)
loss = MSELoss()
with pytest.raises(ValueError, match="No metric provided"):
optimizer.optimize(dataset, loss, {})
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_optimize_without_llm(self, fake_llm):
"""Test that optimize raises ValueError when no llm is set."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
metric = Mock()
optimizer.metric = metric
dataset = Mock(spec=SingleMetricAnnotation)
loss = MSELoss()
with pytest.raises(ValueError, match="No llm provided"):
optimizer.optimize(dataset, loss, {})
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
@patch("ragas.optimizers.dspy_adapter.setup_dspy_llm")
@patch("ragas.optimizers.dspy_adapter.pydantic_prompt_to_dspy_signature")
@patch("ragas.optimizers.dspy_adapter.ragas_dataset_to_dspy_examples")
@patch("ragas.optimizers.dspy_adapter.create_dspy_metric")
def test_optimize_basic_flow(
self,
mock_create_metric,
mock_to_examples,
mock_to_signature,
mock_setup_llm,
fake_llm,
):
"""Test basic optimization flow with mocked DSPy."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_metric = Mock()
mock_metric.name = "test_metric"
mock_metric.get_prompts.return_value = {
"test_prompt": Mock(instruction="Test instruction")
}
optimizer.metric = mock_metric
optimizer.llm = fake_llm
mock_dspy = MagicMock()
mock_signature = Mock()
mock_to_signature.return_value = mock_signature
mock_module = Mock()
mock_dspy.Predict.return_value = mock_module
mock_examples = [Mock()]
mock_to_examples.return_value = mock_examples
mock_metric_fn = Mock()
mock_create_metric.return_value = mock_metric_fn
mock_teleprompter = Mock()
mock_optimized = Mock()
mock_optimized.signature.instructions = "Optimized instruction"
mock_teleprompter.compile.return_value = mock_optimized
mock_dspy.MIPROv2.return_value = mock_teleprompter
optimizer._dspy = mock_dspy
dataset = Mock(spec=SingleMetricAnnotation)
dataset.name = "test_metric"
loss = MSELoss()
result = optimizer.optimize(dataset, loss, {})
assert "test_prompt" in result
assert result["test_prompt"] == "Optimized instruction"
mock_setup_llm.assert_called_once_with(mock_dspy, fake_llm)
mock_metric.get_prompts.assert_called_once()
mock_to_signature.assert_called_once()
mock_to_examples.assert_called_once()
mock_create_metric.assert_called_once_with(loss, "test_metric")
mock_dspy.MIPROv2.assert_called_once_with(
num_candidates=10,
max_bootstrapped_demos=5,
max_labeled_demos=5,
init_temperature=1.0,
auto="light",
num_threads=None,
max_errors=None,
seed=9,
verbose=False,
track_stats=True,
log_dir=None,
metric_threshold=None,
)
mock_teleprompter.compile.assert_called_once_with(
mock_module,
trainset=mock_examples,
metric=mock_metric_fn,
)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
@patch("ragas.optimizers.dspy_adapter.setup_dspy_llm")
@patch("ragas.optimizers.dspy_adapter.pydantic_prompt_to_dspy_signature")
@patch("ragas.optimizers.dspy_adapter.ragas_dataset_to_dspy_examples")
@patch("ragas.optimizers.dspy_adapter.create_dspy_metric")
def test_optimize_with_custom_params(
self,
mock_create_metric,
mock_to_examples,
mock_to_signature,
mock_setup_llm,
fake_llm,
):
"""Test that custom parameters are passed to MIPROv2."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer(
num_candidates=15,
max_bootstrapped_demos=7,
max_labeled_demos=6,
init_temperature=0.8,
auto="heavy",
num_threads=4,
max_errors=5,
seed=42,
verbose=True,
track_stats=False,
log_dir="/tmp/dspy",
metric_threshold=0.85,
)
mock_metric = Mock()
mock_metric.name = "test_metric"
mock_metric.get_prompts.return_value = {
"test_prompt": Mock(instruction="Test instruction")
}
optimizer.metric = mock_metric
optimizer.llm = fake_llm
mock_dspy = MagicMock()
mock_signature = Mock()
mock_to_signature.return_value = mock_signature
mock_module = Mock()
mock_dspy.Predict.return_value = mock_module
mock_examples = [Mock()]
mock_to_examples.return_value = mock_examples
mock_metric_fn = Mock()
mock_create_metric.return_value = mock_metric_fn
mock_teleprompter = Mock()
mock_optimized = Mock()
mock_optimized.signature.instructions = "Optimized instruction"
mock_teleprompter.compile.return_value = mock_optimized
mock_dspy.MIPROv2.return_value = mock_teleprompter
optimizer._dspy = mock_dspy
dataset = Mock(spec=SingleMetricAnnotation)
dataset.name = "test_metric"
loss = MSELoss()
result = optimizer.optimize(dataset, loss, {})
assert "test_prompt" in result
mock_dspy.MIPROv2.assert_called_once_with(
num_candidates=15,
max_bootstrapped_demos=7,
max_labeled_demos=6,
init_temperature=0.8,
auto="heavy",
num_threads=4,
max_errors=5,
seed=42,
verbose=True,
track_stats=False,
log_dir="/tmp/dspy",
metric_threshold=0.85,
)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_extract_instruction_from_signature(self):
"""Test extracting instruction from optimized module with signature.instructions."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_module = Mock()
mock_module.signature.instructions = "Test instruction"
result = optimizer._extract_instruction(mock_module)
assert result == "Test instruction"
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_extract_instruction_from_docstring(self):
"""Test extracting instruction from signature.__doc__."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_module = Mock()
del mock_module.signature.instructions
mock_module.signature.__doc__ = "Doc instruction"
result = optimizer._extract_instruction(mock_module)
assert result == "Doc instruction"
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_extract_instruction_from_extended_signature(self):
"""Test extracting instruction from extended_signature."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_module = Mock()
del mock_module.signature
mock_module.extended_signature = "Extended instruction"
result = optimizer._extract_instruction(mock_module)
assert result == "Extended instruction"
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_extract_instruction_fallback(self):
"""Test extracting instruction returns empty string as fallback."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_module = Mock(spec=[])
result = optimizer._extract_instruction(mock_module)
assert result == ""
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_cache_key_generation(self, fake_llm):
"""Test cache key generation is deterministic."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_metric = Mock()
mock_metric.name = "test_metric"
optimizer.metric = mock_metric
optimizer.llm = fake_llm
dataset = Mock(spec=SingleMetricAnnotation)
dataset.model_dump.return_value = {"data": "test"}
loss = MSELoss()
config = {"test": "config"}
key1 = optimizer._generate_cache_key(dataset, loss, config)
key2 = optimizer._generate_cache_key(dataset, loss, config)
assert key1 == key2
assert isinstance(key1, str)
assert len(key1) == 64
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_cache_key_different_for_different_inputs(self, fake_llm):
"""Test cache key changes with different inputs."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer()
mock_metric = Mock()
mock_metric.name = "test_metric"
optimizer.metric = mock_metric
optimizer.llm = fake_llm
dataset1 = Mock(spec=SingleMetricAnnotation)
dataset1.model_dump.return_value = {"data": "test1"}
dataset2 = Mock(spec=SingleMetricAnnotation)
dataset2.model_dump.return_value = {"data": "test2"}
loss = MSELoss()
config = {"test": "config"}
key1 = optimizer._generate_cache_key(dataset1, loss, config)
key2 = optimizer._generate_cache_key(dataset2, loss, config)
assert key1 != key2
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
@patch("ragas.optimizers.dspy_adapter.setup_dspy_llm")
@patch("ragas.optimizers.dspy_adapter.pydantic_prompt_to_dspy_signature")
@patch("ragas.optimizers.dspy_adapter.ragas_dataset_to_dspy_examples")
@patch("ragas.optimizers.dspy_adapter.create_dspy_metric")
def test_cache_hit(
self,
mock_create_metric,
mock_to_examples,
mock_to_signature,
mock_setup_llm,
fake_llm,
):
"""Test that cached results are returned on cache hit."""
from ragas.cache import DiskCacheBackend
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
cache = DiskCacheBackend(cache_dir=".test_cache_dspy")
optimizer = DSPyOptimizer(cache=cache)
mock_metric = Mock()
mock_metric.name = "test_metric"
mock_metric.get_prompts.return_value = {
"test_prompt": Mock(instruction="Test instruction")
}
optimizer.metric = mock_metric
optimizer.llm = fake_llm
mock_dspy = MagicMock()
mock_signature = Mock()
mock_to_signature.return_value = mock_signature
mock_module = Mock()
mock_dspy.Predict.return_value = mock_module
mock_examples = [Mock()]
mock_to_examples.return_value = mock_examples
mock_metric_fn = Mock()
mock_create_metric.return_value = mock_metric_fn
mock_teleprompter = Mock()
mock_optimized = Mock()
mock_optimized.signature.instructions = "Optimized instruction"
mock_teleprompter.compile.return_value = mock_optimized
mock_dspy.MIPROv2.return_value = mock_teleprompter
optimizer._dspy = mock_dspy
dataset = Mock(spec=SingleMetricAnnotation)
dataset.name = "test_metric"
dataset.model_dump.return_value = {"data": "test"}
loss = MSELoss()
result1 = optimizer.optimize(dataset, loss, {})
assert mock_teleprompter.compile.call_count == 1
result2 = optimizer.optimize(dataset, loss, {})
assert mock_teleprompter.compile.call_count == 1
assert result1 == result2
assert result1["test_prompt"] == "Optimized instruction"
cache.cache.close()
import shutil
shutil.rmtree(".test_cache_dspy", ignore_errors=True)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
@patch("ragas.optimizers.dspy_adapter.setup_dspy_llm")
@patch("ragas.optimizers.dspy_adapter.pydantic_prompt_to_dspy_signature")
@patch("ragas.optimizers.dspy_adapter.ragas_dataset_to_dspy_examples")
@patch("ragas.optimizers.dspy_adapter.create_dspy_metric")
def test_cache_miss(
self,
mock_create_metric,
mock_to_examples,
mock_to_signature,
mock_setup_llm,
fake_llm,
):
"""Test that optimization runs on cache miss."""
from ragas.cache import DiskCacheBackend
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
cache = DiskCacheBackend(cache_dir=".test_cache_dspy_miss")
optimizer = DSPyOptimizer(cache=cache)
mock_metric = Mock()
mock_metric.name = "test_metric"
mock_metric.get_prompts.return_value = {
"test_prompt": Mock(instruction="Test instruction")
}
optimizer.metric = mock_metric
optimizer.llm = fake_llm
mock_dspy = MagicMock()
mock_signature = Mock()
mock_to_signature.return_value = mock_signature
mock_module = Mock()
mock_dspy.Predict.return_value = mock_module
mock_examples = [Mock()]
mock_to_examples.return_value = mock_examples
mock_metric_fn = Mock()
mock_create_metric.return_value = mock_metric_fn
mock_teleprompter = Mock()
mock_optimized = Mock()
mock_optimized.signature.instructions = "Optimized instruction"
mock_teleprompter.compile.return_value = mock_optimized
mock_dspy.MIPROv2.return_value = mock_teleprompter
optimizer._dspy = mock_dspy
dataset1 = Mock(spec=SingleMetricAnnotation)
dataset1.name = "test_metric"
dataset1.model_dump.return_value = {"data": "test1"}
dataset2 = Mock(spec=SingleMetricAnnotation)
dataset2.name = "test_metric"
dataset2.model_dump.return_value = {"data": "test2"}
loss = MSELoss()
result1 = optimizer.optimize(dataset1, loss, {})
assert mock_teleprompter.compile.call_count == 1
result2 = optimizer.optimize(dataset2, loss, {})
assert mock_teleprompter.compile.call_count == 2
assert result1["test_prompt"] == "Optimized instruction"
assert result2["test_prompt"] == "Optimized instruction"
cache.cache.close()
import shutil
shutil.rmtree(".test_cache_dspy_miss", ignore_errors=True)
@pytest.mark.skipif(not DSPY_AVAILABLE, reason="dspy-ai not installed")
def test_optimize_without_cache(self, fake_llm):
"""Test that optimization works without cache configured."""
from ragas.optimizers.dspy_optimizer import DSPyOptimizer
optimizer = DSPyOptimizer(cache=None)
assert optimizer.cache is None