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

453 lines
16 KiB
Python

"""E2E tests for string metrics migration from v1 to v2."""
import pytest
from ragas.dataset_schema import SingleTurnSample
from ragas.metrics import MetricResult
from ragas.metrics._string import (
DistanceMeasure as LegacyDistanceMeasure,
ExactMatch as LegacyExactMatch,
NonLLMStringSimilarity as LegacyNonLLMStringSimilarity,
StringPresence as LegacyStringPresence,
)
from ragas.metrics.collections import (
DistanceMeasure,
ExactMatch,
NonLLMStringSimilarity,
StringPresence,
)
class TestNonLLMStringSimilarityE2EMigration:
"""E2E test compatibility between legacy and new V2 implementations."""
@pytest.fixture
def sample_data(self):
"""Real-world sample reference and response texts for testing."""
return [
{
"reference": "The cat sat on the mat",
"response": "The cat sat on the mat",
"description": "Exact match",
},
{
"reference": "Hello World",
"response": "Hallo World",
"description": "Single character difference",
},
{
"reference": "Python is a programming language",
"response": "Python is a scripting language",
"description": "Word substitution",
},
{
"reference": "The capital of France is Paris",
"response": "Paris is the capital of France",
"description": "Word reordering",
},
{
"reference": "Machine learning",
"response": "Deep learning",
"description": "Partial similarity",
},
{
"reference": "test",
"response": "test",
"description": "Short exact match",
},
{
"reference": "abc",
"response": "xyz",
"description": "Completely different",
},
{
"reference": "",
"response": "Some text",
"description": "Empty reference",
},
{
"reference": "Some text",
"response": "",
"description": "Empty response",
},
]
@pytest.mark.asyncio
async def test_legacy_vs_v2_class_e2e_compatibility_levenshtein(self, sample_data):
"""E2E test that legacy and v2 class implementations produce identical scores (Levenshtein)."""
for i, data in enumerate(sample_data):
print(
f"\n🧪 Testing NonLLMStringSimilarity (Levenshtein) - Case {i + 1}: {data['description']}"
)
print(f" Reference: '{data['reference']}'")
print(f" Response: '{data['response']}'")
legacy_metric = LegacyNonLLMStringSimilarity(
distance_measure=LegacyDistanceMeasure.LEVENSHTEIN
)
legacy_sample = SingleTurnSample(
user_input="dummy",
response=data["response"],
reference=data["reference"],
)
legacy_score = await legacy_metric._single_turn_ascore(legacy_sample, None)
v2_class_metric = NonLLMStringSimilarity(
distance_measure=DistanceMeasure.LEVENSHTEIN
)
v2_class_result = await v2_class_metric.ascore(
reference=data["reference"],
response=data["response"],
)
class_diff = abs(legacy_score - v2_class_result.value)
print(f" Legacy: {legacy_score:.6f}")
print(f" V2 Class: {v2_class_result.value:.6f}")
print(f" Diff: {class_diff:.10f}")
assert class_diff < 1e-10, (
f"Case {i + 1} ({data['description']}): Levenshtein mismatch: "
f"{legacy_score} != {v2_class_result.value}"
)
assert isinstance(legacy_score, float)
assert isinstance(v2_class_result, MetricResult)
print(" ✅ Legacy and V2 class produce identical scores!")
@pytest.mark.asyncio
async def test_legacy_vs_v2_class_e2e_compatibility_jaro_winkler(self, sample_data):
"""E2E test that legacy and v2 class implementations produce identical scores (Jaro-Winkler)."""
for i, data in enumerate(sample_data[:5]):
print(
f"\n🧪 Testing NonLLMStringSimilarity (Jaro-Winkler) - Case {i + 1}: {data['description']}"
)
print(f" Reference: '{data['reference']}'")
print(f" Response: '{data['response']}'")
legacy_metric = LegacyNonLLMStringSimilarity(
distance_measure=LegacyDistanceMeasure.JARO_WINKLER
)
legacy_sample = SingleTurnSample(
user_input="dummy",
response=data["response"],
reference=data["reference"],
)
legacy_score = await legacy_metric._single_turn_ascore(legacy_sample, None)
v2_class_metric = NonLLMStringSimilarity(
distance_measure=DistanceMeasure.JARO_WINKLER
)
v2_class_result = await v2_class_metric.ascore(
reference=data["reference"],
response=data["response"],
)
class_diff = abs(legacy_score - v2_class_result.value)
print(f" Legacy: {legacy_score:.6f}")
print(f" V2 Class: {v2_class_result.value:.6f}")
print(f" Diff: {class_diff:.10f}")
assert class_diff < 1e-10, (
f"Case {i + 1} ({data['description']}): Jaro-Winkler mismatch: "
f"{legacy_score} != {v2_class_result.value}"
)
assert isinstance(legacy_score, float)
assert isinstance(v2_class_result, MetricResult)
print(" ✅ Legacy and V2 class produce identical scores!")
@pytest.mark.asyncio
async def test_all_distance_measures(self):
"""Test that all distance measures work correctly in v2."""
print("\n🔧 Testing all distance measures:")
reference = "The quick brown fox"
response = "The quick brown dog"
for measure in DistanceMeasure:
metric = NonLLMStringSimilarity(distance_measure=measure)
result = await metric.ascore(reference=reference, response=response)
print(f" {measure.value:15s}: {result.value:.6f}")
assert isinstance(result.value, float)
assert 0.0 <= result.value <= 1.0
print(" ✅ All distance measures work correctly!")
@pytest.mark.asyncio
async def test_performance_comparison(self, sample_data):
"""Compare performance characteristics between legacy and v2 class."""
import time
test_case = sample_data[3]
print("\n⚡ Performance test: NonLLMStringSimilarity")
legacy_metric = LegacyNonLLMStringSimilarity()
legacy_sample = SingleTurnSample(
user_input="dummy",
response=test_case["response"],
reference=test_case["reference"],
)
start_time = time.time()
legacy_score = await legacy_metric._single_turn_ascore(legacy_sample, None)
legacy_time = time.time() - start_time
v2_class_metric = NonLLMStringSimilarity()
start_time = time.time()
v2_class_result = await v2_class_metric.ascore(
reference=test_case["reference"],
response=test_case["response"],
)
v2_class_time = time.time() - start_time
print(f" Legacy: {legacy_time:.4f}s → {legacy_score:.6f}")
print(f" V2 Class: {v2_class_time:.4f}s → {v2_class_result.value:.6f}")
assert abs(legacy_score - v2_class_result.value) < 1e-10
assert isinstance(legacy_score, float)
assert isinstance(v2_class_result, MetricResult)
@pytest.mark.asyncio
async def test_v2_class_no_components_needed(self):
"""Test that V2 class-based NonLLMStringSimilarity doesn't require LLM or embeddings."""
print("\n🔧 Testing V2 NonLLMStringSimilarity component requirements:")
metric = NonLLMStringSimilarity()
print(f" has llm attr: {hasattr(metric, 'llm')}")
print(f" has embeddings attr: {hasattr(metric, 'embeddings')}")
result = await metric.ascore(
reference="The capital of France is Paris.",
response="Paris is the capital of France.",
)
print(f" Score: {result.value:.6f}")
assert not hasattr(metric, "llm") or metric.__dict__.get("llm") is None
assert (
not hasattr(metric, "embeddings")
or metric.__dict__.get("embeddings") is None
)
assert isinstance(result.value, float)
assert 0.0 <= result.value <= 1.0
print(" ✅ V2 NonLLMStringSimilarity works without LLM/embeddings!")
@pytest.mark.asyncio
async def test_v2_class_batch_processing(self, sample_data):
"""Test V2 class-based NonLLMStringSimilarity batch processing."""
metric = NonLLMStringSimilarity()
batch_inputs = [
{"reference": case["reference"], "response": case["response"]}
for case in sample_data[:4]
]
print(f"\n📦 Testing V2 class batch processing with {len(batch_inputs)} items:")
results = await metric.abatch_score(batch_inputs)
assert len(results) == len(batch_inputs)
for i, (case, result) in enumerate(zip(sample_data[:4], results)):
print(f" Case {i + 1}: {result.value:.6f} - {case['description']}")
assert isinstance(result.value, float)
assert -1e-10 <= result.value <= 1.0 + 1e-10
assert result.reason is None
print(" ✅ V2 class batch processing works correctly!")
@pytest.mark.asyncio
async def test_edge_cases(self):
"""Test edge cases like empty strings."""
print("\n🔍 Testing edge cases:")
metric = NonLLMStringSimilarity()
cases = [
("", "", "Both empty"),
("test", "", "Empty response"),
("", "test", "Empty reference"),
("a", "a", "Single character match"),
]
for ref, resp, desc in cases:
result = await metric.ascore(reference=ref, response=resp)
print(f" {desc:25s}: {result.value:.6f}")
assert isinstance(result.value, float)
assert 0.0 <= result.value <= 1.0
print(" ✅ Edge cases handled correctly!")
class TestExactMatchE2EMigration:
"""E2E test compatibility between legacy ExactMatch and new V2 implementations."""
@pytest.fixture
def sample_data(self):
"""Sample data for ExactMatch testing."""
return [
{
"reference": "Hello World",
"response": "Hello World",
"description": "Exact match",
},
{
"reference": "Hello World",
"response": "hello world",
"description": "Case mismatch",
},
{
"reference": "Test",
"response": "Test ",
"description": "Trailing space",
},
{
"reference": "",
"response": "",
"description": "Both empty",
},
{
"reference": "abc",
"response": "xyz",
"description": "Completely different",
},
]
@pytest.mark.asyncio
async def test_legacy_vs_v2_class_e2e_compatibility(self, sample_data):
"""E2E test that legacy and v2 class implementations produce identical scores."""
for i, data in enumerate(sample_data):
print(f"\n🧪 Testing ExactMatch - Case {i + 1}: {data['description']}")
print(f" Reference: '{data['reference']}'")
print(f" Response: '{data['response']}'")
legacy_metric = LegacyExactMatch()
legacy_sample = SingleTurnSample(
user_input="dummy",
response=data["response"],
reference=data["reference"],
)
legacy_score = await legacy_metric._single_turn_ascore(legacy_sample, None)
v2_class_metric = ExactMatch()
v2_class_result = await v2_class_metric.ascore(
reference=data["reference"],
response=data["response"],
)
class_diff = abs(legacy_score - v2_class_result.value)
print(f" Legacy: {legacy_score:.6f}")
print(f" V2 Class: {v2_class_result.value:.6f}")
print(f" Diff: {class_diff:.10f}")
assert class_diff < 1e-10, (
f"Case {i + 1} ({data['description']}): ExactMatch mismatch: "
f"{legacy_score} != {v2_class_result.value}"
)
assert isinstance(legacy_score, float)
assert isinstance(v2_class_result, MetricResult)
print(" ✅ Legacy and V2 class produce identical scores!")
class TestStringPresenceE2EMigration:
"""E2E test compatibility between legacy StringPresence and new V2 implementations."""
@pytest.fixture
def sample_data(self):
"""Sample data for StringPresence testing."""
return [
{
"reference": "Paris",
"response": "The capital of France is Paris.",
"description": "String present",
},
{
"reference": "cat",
"response": "The cat sat on the mat",
"description": "String present in middle",
},
{
"reference": "dog",
"response": "The cat sat on the mat",
"description": "String not present",
},
{
"reference": "Hello",
"response": "Hello World",
"description": "String at start",
},
{
"reference": "World",
"response": "Hello World",
"description": "String at end",
},
{
"reference": "",
"response": "Some text",
"description": "Empty reference",
},
{
"reference": "test",
"response": "",
"description": "Empty response",
},
]
@pytest.mark.asyncio
async def test_legacy_vs_v2_class_e2e_compatibility(self, sample_data):
"""E2E test that legacy and v2 class implementations produce identical scores."""
for i, data in enumerate(sample_data):
print(f"\n🧪 Testing StringPresence - Case {i + 1}: {data['description']}")
print(f" Reference: '{data['reference']}'")
print(f" Response: '{data['response']}'")
legacy_metric = LegacyStringPresence()
legacy_sample = SingleTurnSample(
user_input="dummy",
response=data["response"],
reference=data["reference"],
)
legacy_score = await legacy_metric._single_turn_ascore(legacy_sample, None)
v2_class_metric = StringPresence()
v2_class_result = await v2_class_metric.ascore(
reference=data["reference"],
response=data["response"],
)
class_diff = abs(legacy_score - v2_class_result.value)
print(f" Legacy: {legacy_score:.6f}")
print(f" V2 Class: {v2_class_result.value:.6f}")
print(f" Diff: {class_diff:.10f}")
assert class_diff < 1e-10, (
f"Case {i + 1} ({data['description']}): StringPresence mismatch: "
f"{legacy_score} != {v2_class_result.value}"
)
assert isinstance(legacy_score, float)
assert isinstance(v2_class_result, MetricResult)
print(" ✅ Legacy and V2 class produce identical scores!")