Files
confident-ai--deepeval/tests/test_core/test_sanitize_nan.py
T
2026-07-13 13:32:05 +08:00

228 lines
7.9 KiB
Python

"""Tests for NaN / Infinity / -Infinity sanitization.
Validates that non-finite floats are replaced with None before JSON
serialization so payloads sent to the backend are always valid JSON.
"""
import json
import math
import pytest
from deepeval.tracing.utils import (
make_json_serializable,
make_json_serializable_for_metadata,
)
from deepeval.confident.api import _sanitize_body
# ---------------------------------------------------------------------------
# make_json_serializable
# ---------------------------------------------------------------------------
class TestMakeJsonSerializable:
"""make_json_serializable must neutralise non-finite floats."""
def test_nan_replaced_with_none(self):
assert make_json_serializable(float("nan")) is None
def test_inf_replaced_with_none(self):
assert make_json_serializable(float("inf")) is None
def test_neg_inf_replaced_with_none(self):
assert make_json_serializable(float("-inf")) is None
def test_normal_float_preserved(self):
assert make_json_serializable(3.14) == 3.14
def test_zero_float_preserved(self):
assert make_json_serializable(0.0) == 0.0
def test_negative_float_preserved(self):
assert make_json_serializable(-1.5) == -1.5
def test_nan_inside_dict(self):
result = make_json_serializable({"score": float("nan"), "ok": 1.0})
assert result["score"] is None
assert result["ok"] == 1.0
def test_nan_inside_list(self):
result = make_json_serializable([1.0, float("nan"), float("inf")])
assert result == [1.0, None, None]
def test_deeply_nested(self):
obj = {"level1": {"level2": [{"value": float("nan")}, {"value": 42.0}]}}
result = make_json_serializable(obj)
assert result["level1"]["level2"][0]["value"] is None
assert result["level1"]["level2"][1]["value"] == 42.0
def test_result_is_valid_json(self):
"""The whole point: the output must survive json.dumps / json.loads."""
payload = {
"score": float("nan"),
"threshold": 0.5,
"cost": float("inf"),
"neg": float("-inf"),
"nested": {"v": float("nan")},
"items": [float("inf"), 1.0],
}
sanitized = make_json_serializable(payload)
roundtripped = json.loads(json.dumps(sanitized))
assert roundtripped["score"] is None
assert roundtripped["threshold"] == 0.5
assert roundtripped["cost"] is None
assert roundtripped["neg"] is None
assert roundtripped["nested"]["v"] is None
assert roundtripped["items"] == [None, 1.0]
def test_other_types_unaffected(self):
result = make_json_serializable(
{"s": "hello", "i": 42, "b": True, "n": None}
)
assert result == {"s": "hello", "i": 42, "b": True, "n": None}
# ---------------------------------------------------------------------------
# make_json_serializable_for_metadata
# ---------------------------------------------------------------------------
class TestMakeJsonSerializableForMetadata:
"""metadata variant preserves finite primitives, replaces non-finite with None.
Previously this helper stringified every primitive (``True`` → ``"True"``,
``3.14`` → ``"3.14"``), which destroyed type fidelity for user metadata.
The contract is now: primitives pass through, non-finite floats become
None, everything else gets serialized recursively.
"""
def test_nan_replaced_with_none(self):
assert make_json_serializable_for_metadata(float("nan")) is None
def test_inf_replaced_with_none(self):
assert make_json_serializable_for_metadata(float("inf")) is None
def test_neg_inf_replaced_with_none(self):
assert make_json_serializable_for_metadata(float("-inf")) is None
def test_finite_float_preserved(self):
assert make_json_serializable_for_metadata(3.14) == 3.14
def test_int_preserved(self):
assert make_json_serializable_for_metadata(42) == 42
def test_bool_preserved(self):
assert make_json_serializable_for_metadata(True) is True
assert make_json_serializable_for_metadata(False) is False
def test_none_preserved(self):
assert make_json_serializable_for_metadata(None) is None
def test_nan_inside_dict(self):
result = make_json_serializable_for_metadata(
{"cost": float("nan"), "ok": 2.0}
)
assert result["cost"] is None
assert result["ok"] == 2.0
def test_mixed_primitives_inside_dict(self):
"""Regression guard: every primitive type must round-trip with its
native JSON type intact."""
result = make_json_serializable_for_metadata(
{
"flag": True,
"count": 7,
"ratio": 0.25,
"missing": None,
"label": "ok",
}
)
assert result == {
"flag": True,
"count": 7,
"ratio": 0.25,
"missing": None,
"label": "ok",
}
# ---------------------------------------------------------------------------
# _sanitize_body (API-layer catch-all)
# ---------------------------------------------------------------------------
class TestSanitizeBody:
"""_sanitize_body is the last line of defence before HTTP serialization."""
def test_nan(self):
assert _sanitize_body(float("nan")) is None
def test_inf(self):
assert _sanitize_body(float("inf")) is None
def test_neg_inf(self):
assert _sanitize_body(float("-inf")) is None
def test_normal_float(self):
assert _sanitize_body(3.14) == 3.14
def test_flat_dict(self):
result = _sanitize_body({"a": float("nan"), "b": 1.0, "c": "hi"})
assert result == {"a": None, "b": 1.0, "c": "hi"}
def test_nested_dict(self):
result = _sanitize_body({"outer": {"inner": float("inf")}})
assert result == {"outer": {"inner": None}}
def test_list(self):
result = _sanitize_body([float("nan"), 1, "x", float("-inf")])
assert result == [None, 1, "x", None]
def test_tuple_becomes_list(self):
result = _sanitize_body((float("nan"), 2.0))
assert result == [None, 2.0]
def test_non_numeric_passthrough(self):
assert _sanitize_body("hello") == "hello"
assert _sanitize_body(42) == 42
assert _sanitize_body(True) is True
assert _sanitize_body(None) is None
def test_full_trace_shaped_payload(self):
"""Simulate a realistic trace payload with problematic values."""
payload = {
"uuid": "abc-123",
"baseSpans": [],
"llmSpans": [
{
"uuid": "span-1",
"inputTokenCount": float("nan"),
"outputTokenCount": float("inf"),
"costPerInputToken": float("-inf"),
"costPerOutputToken": 0.00003,
"metricsData": [
{
"name": "faithfulness",
"score": float("nan"),
"threshold": 0.7,
"evaluationCost": float("inf"),
}
],
}
],
"startTime": "2025-01-01T00:00:00Z",
"endTime": "2025-01-01T00:00:01Z",
}
sanitized = _sanitize_body(payload)
span = sanitized["llmSpans"][0]
assert span["inputTokenCount"] is None
assert span["outputTokenCount"] is None
assert span["costPerInputToken"] is None
assert span["costPerOutputToken"] == 0.00003
metric = span["metricsData"][0]
assert metric["score"] is None
assert metric["threshold"] == 0.7
assert metric["evaluationCost"] is None
roundtripped = json.loads(json.dumps(sanitized))
assert roundtripped is not None