Files
2026-07-13 13:22:34 +08:00

2305 lines
79 KiB
Python

import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from unittest import mock
from unittest.mock import ANY, MagicMock
import pandas as pd
import pytest
import mlflow
from mlflow.entities.assessment import Expectation, Feedback
from mlflow.entities.assessment_source import AssessmentSource, AssessmentSourceType
from mlflow.entities.span import SpanType
from mlflow.entities.trace import Trace
from mlflow.entities.trace_data import TraceData
from mlflow.exceptions import MlflowException
from mlflow.genai.datasets import EvaluationDataset, create_dataset
from mlflow.genai.evaluation.entities import EvalItem, EvalResult, EvaluationResult
from mlflow.genai.evaluation.harness import (
AUTO_INITIAL_RPS,
NoOpRateLimiter,
_get_new_expectations,
_run_predict,
_ScoreSubmitter,
_should_clone_trace,
backpressure_buffer,
)
from mlflow.genai.evaluation.rate_limiter import RPSRateLimiter
from mlflow.genai.scorers.base import scorer
from mlflow.genai.scorers.builtin_scorers import RelevanceToQuery
from mlflow.genai.simulators import ConversationSimulator
from mlflow.server import handlers
from mlflow.server.fastapi_app import app
from mlflow.server.handlers import initialize_backend_stores
from mlflow.tracing.constant import AssessmentMetadataKey, TraceMetadataKey
from tests.helper_functions import get_safe_port
from tests.tracing.helper import (
create_test_trace_info,
create_test_trace_info_with_uc_table,
get_traces,
)
from tests.tracking.integration_test_utils import ServerThread
@pytest.fixture
def mlflow_experiment_trace():
return Trace(
info=create_test_trace_info(trace_id="tr-123", experiment_id="exp-123"),
data=TraceData(spans=[]),
)
_DUMMY_CHAT_RESPONSE = {
"id": "1",
"object": "text_completion",
"created": "2021-10-01T00:00:00.000000Z",
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"content": "This is a response",
"role": "assistant",
},
"finish_reason": "length",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
class TestModel:
def predict(self, question: str) -> str:
return "I don't know"
@scorer
def exact_match(outputs, expectations):
return outputs == expectations["expected_response"]
@scorer
def is_concise(outputs, expectations):
return len(outputs) <= expectations["max_length"]
@scorer
def relevance(inputs, outputs):
return Feedback(
name="relevance",
value="yes",
rationale="The response is relevant to the question",
source=AssessmentSource(source_id="gpt", source_type="LLM_JUDGE"),
)
@scorer
@mlflow.trace(span_type=SpanType.EVALUATOR)
def has_trace(trace):
return trace is not None
class FailingSessionScorer(mlflow.genai.Scorer):
def __init__(self):
super().__init__(name="failing_session_scorer")
@property
def is_session_level_scorer(self) -> bool:
return True
def __call__(self, session=None, **kwargs):
raise ValueError("Session scorer error")
class WorkingSessionScorer(mlflow.genai.Scorer):
def __init__(self):
super().__init__(name="working_session_scorer")
@property
def is_session_level_scorer(self) -> bool:
return True
def __call__(self, session=None, **kwargs):
return len(session or [])
def _validate_assessments(traces):
"""Validate assessments are added to the traces"""
for trace in traces:
assert len(trace.info.assessments) == 6, (
f"Expected 6 assessments, got {len(trace.info.assessments)}"
f"Assessments: {[a.name for a in trace.info.assessments]}"
) # 2 expectations + 4 feedbacks
assessments = {a.name: a for a in trace.info.assessments}
a_exact_match = assessments["exact_match"]
assert isinstance(a_exact_match, Feedback)
assert a_exact_match.trace_id == trace.info.trace_id
assert isinstance(a_exact_match.value, bool)
assert a_exact_match.source.source_type == AssessmentSourceType.CODE
# Scorer name is used as source_id
assert a_exact_match.source.source_id == "exact_match"
assert a_exact_match.metadata[AssessmentMetadataKey.SOURCE_RUN_ID] is not None
a_is_concise = assessments["is_concise"]
assert isinstance(a_is_concise, Feedback)
assert isinstance(a_is_concise.value, bool)
assert a_is_concise.metadata[AssessmentMetadataKey.SOURCE_RUN_ID] is not None
a_has_trace = assessments["has_trace"]
assert isinstance(a_has_trace, Feedback)
assert a_has_trace.value is True
assert a_has_trace.metadata[AssessmentMetadataKey.SOURCE_RUN_ID] is not None
a_relevance = assessments["relevance"]
assert isinstance(a_relevance, Feedback)
assert a_relevance.value == "yes"
assert a_relevance.source.source_id == "gpt"
assert a_relevance.source.source_type == "LLM_JUDGE"
assert a_relevance.rationale == "The response is relevant to the question"
assert a_relevance.metadata[AssessmentMetadataKey.SOURCE_RUN_ID] is not None
a_expected_response = assessments["expected_response"]
assert isinstance(a_expected_response, Expectation)
assert isinstance(a_expected_response.value, str)
assert a_expected_response.source.source_type == AssessmentSourceType.HUMAN
assert a_expected_response.source.source_id is not None
a_max_length = assessments["max_length"]
assert isinstance(a_max_length, Expectation)
assert isinstance(a_max_length.value, (int, float))
assert a_max_length.source.source_type == AssessmentSourceType.HUMAN
def _validate_eval_result_df(result: EvaluationResult):
search_traces_df = mlflow.search_traces(run_id=result.run_id)
assert result.result_df is not None
assert len(result.result_df) == len(search_traces_df)
assert set(result.result_df.columns) >= set(search_traces_df.columns)
actual = result.result_df.sort_values(by="trace_id").reset_index(drop=True)
expected = search_traces_df.sort_values(by="trace_id").reset_index(drop=True)
for i in range(len(actual)):
assert actual.iloc[i].trace_id == expected.iloc[i].trace_id
assert actual.iloc[i].spans == expected.iloc[i].spans
assert actual.iloc[i].assessments == expected.iloc[i].assessments
assert actual.iloc[i]["exact_match/value"] is not None
assert actual.iloc[i]["is_concise/value"] is not None
assert actual.iloc[i]["relevance/value"] is not None
assert actual.iloc[i]["has_trace/value"] is not None
assert actual.iloc[i]["expected_response/value"] is not None
assert actual.iloc[i]["max_length/value"] is not None
# backwards compatibility
assert len(result.tables["eval_results"]) == len(result.result_df)
@dataclass
class ServerConfig:
host_type: Literal["local", "remote", "databricks"]
backend_type: Literal["file", "sqlalchemy"] | None = None
# Test with different server configurations
# 1. local file backend
# 2. local sqlalchemy backend
# 3. remote server running on file backend
# 4. remote server running on sqlalchemy backend
@pytest.fixture(
params=[
ServerConfig(host_type="local", backend_type="file"),
ServerConfig(host_type="local", backend_type="sqlalchemy"),
ServerConfig(host_type="remote", backend_type="file"),
ServerConfig(host_type="remote", backend_type="sqlalchemy"),
],
ids=["local_file", "local_sqlalchemy", "remote_file", "remote_sqlalchemy"],
)
def server_config(request, tmp_path: Path, db_uri: str):
"""Provides an MLflow Tracking API client pointed at the local tracking server."""
config = request.param
if config.backend_type == "file":
pytest.skip("FileStore is no longer supported.")
match config.backend_type:
case "file":
backend_uri = tmp_path.joinpath("file").as_uri()
case "sqlalchemy":
backend_uri = db_uri
match config.host_type:
case "local":
mlflow.set_tracking_uri(backend_uri)
yield config
case "remote":
# Force-reset backend stores before each test.
handlers._tracking_store = None
handlers._model_registry_store = None
initialize_backend_stores(backend_uri, default_artifact_root=tmp_path.as_uri())
with ServerThread(app, get_safe_port()) as url:
mlflow.set_tracking_uri(url)
yield config
def test_evaluate_with_static_dataset(server_config):
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
"expectations": {
"expected_response": "MLflow is a tool for ML",
"max_length": 100,
},
},
{
"inputs": {"question": "What is Spark?"},
"outputs": "Spark is a fast data processing engine",
"expectations": {
"expected_response": "Spark is a fast data processing engine",
"max_length": 1,
},
},
]
result = mlflow.genai.evaluate(
data=data,
scorers=[exact_match, is_concise, relevance, has_trace],
)
# OSS evaluator doesn't support metrics aggregation yet.
metrics = result.metrics
assert metrics["exact_match/mean"] == 1.0
assert metrics["is_concise/mean"] == 0.5
assert metrics["relevance/mean"] == 1.0
assert metrics["has_trace/mean"] == 1.0
# Exact number of traces should be generated
traces = get_traces()
assert len(traces) == len(data)
# Traces should be associated with the eval run
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == len(data)
# Re-order traces to match with the order of the input data
traces = sorted(traces, key=lambda t: t.data.spans[0].inputs["question"])
for i in range(len(traces)):
assert len(traces[i].data.spans) == 1
span = traces[i].data.spans[0]
assert span.name == "root_span"
assert span.inputs == data[i]["inputs"]
assert span.outputs == data[i]["outputs"]
_validate_assessments(traces)
_validate_eval_result_df(result)
# Dataset input should be logged to the run
run = mlflow.get_run(result.run_id)
assert len(run.inputs.dataset_inputs) == 1
assert run.inputs.dataset_inputs[0].dataset.name == "dataset"
assert run.inputs.dataset_inputs[0].dataset.source_type == "code"
def test_evaluate_with_empty_scorers_logs_expectations(server_config):
"""Regression test for #23746.
When scorers=[] (no scorers), dataset expectations must still be persisted to the
trace as Expectation assessments. Before the fix, the no-scorers branch in
harness._run_pipeline set EvalResult(assessments=[]) without calling
_get_new_expectations / _log_assessments, so expectations were silently dropped.
"""
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
"expectations": {
"expected_response": "MLflow is a tool for ML",
"max_length": 100,
},
},
{
"inputs": {"question": "What is Spark?"},
"outputs": "Spark is a fast data processing engine",
"expectations": {
"expected_response": "Spark is a fast data processing engine",
"max_length": 1,
},
},
]
# Empty scorers list: this is the regressed code path.
result = mlflow.genai.evaluate(data=data, scorers=[])
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == len(data)
traces = sorted(traces, key=lambda t: t.data.spans[0].inputs["question"])
for i in range(len(traces)):
trace = traces[i]
assessments = {a.name: a for a in trace.info.assessments}
# No scorers ran, so exactly the 2 dataset expectations must be present
# (and no Feedback assessments).
assert len(trace.info.assessments) == 2
assert set(assessments) == {"expected_response", "max_length"}, (
f"Expected only dataset expectations, got {list(assessments)}"
)
a_expected_response = assessments["expected_response"]
assert isinstance(a_expected_response, Expectation)
assert a_expected_response.trace_id == trace.info.trace_id
assert a_expected_response.value == data[i]["expectations"]["expected_response"]
assert a_expected_response.source.source_type == AssessmentSourceType.HUMAN
a_max_length = assessments["max_length"]
assert isinstance(a_max_length, Expectation)
assert a_max_length.value == data[i]["expectations"]["max_length"]
assert a_max_length.source.source_type == AssessmentSourceType.HUMAN
def test_evaluate_with_empty_scorers_logs_dataset_tags(server_config):
"""Regression test for #23746 (tags part).
The no-scorers short-circuit in harness._run_pipeline also skipped the
eval_item.tags -> set_trace_tag step that _run_score performs, so with
scorers=[] dataset tags were silently dropped from the traces.
"""
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
"tags": {"dataset_split": "validation", "case_id": "case-1"},
}
]
result = mlflow.genai.evaluate(data=data, scorers=[])
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == 1
tags = traces[0].info.tags
assert tags["dataset_split"] == "validation"
assert tags["case_id"] == "case-1"
def test_evaluate_passed_respects_scorer_pass_if(server_config):
@scorer(pass_if=lambda v: v >= 0.8)
def confidence(outputs):
return 0.9 if outputs == "good" else 0.5
passing = mlflow.genai.evaluate(
data=[{"inputs": {"q": "x"}, "outputs": "good"}],
scorers=[confidence],
)
assert passing.pass_criteria.get("confidence") is not None
assert passing.passed, passing.reason
failing = mlflow.genai.evaluate(
data=[{"inputs": {"q": "x"}, "outputs": "bad"}],
scorers=[confidence],
)
assert not failing.passed
assert "confidence" in failing.reason
def test_evaluate_numeric_value_without_pass_if_fails_loudly(server_config):
@scorer
def confidence(outputs):
return 0.9
result = mlflow.genai.evaluate(
data=[{"inputs": {"q": "x"}, "outputs": "good"}],
scorers=[confidence],
)
# A bare numeric value is not guessed as pass/fail; the user must declare pass_if.
assert not result.passed
assert "pass_if" in result.reason
def test_evaluate_errored_scorer_fails_not_silently_passes(server_config):
@scorer
def boom(outputs):
raise ValueError("kaboom")
result = mlflow.genai.evaluate(
data=[{"inputs": {"q": "x"}, "outputs": "good"}],
scorers=[boom],
)
assert not result.passed
assert "boom" in result.reason
assert "kaboom" in result.reason
def test_evaluate_reason_includes_scorer_rationale(server_config):
@scorer
def judged(outputs):
return Feedback(value="no", rationale="answer was wrong")
result = mlflow.genai.evaluate(
data=[{"inputs": {"q": "x"}, "outputs": "good"}],
scorers=[judged],
)
assert not result.passed
assert "answer was wrong" in result.reason
@pytest.mark.parametrize("is_predict_fn_traced", [True, False])
def test_evaluate_with_predict_fn(is_predict_fn_traced, server_config):
model_id = mlflow.set_active_model(name="test-model-id").model_id
data = [
{
"inputs": {"question": "What is MLflow?"},
"expectations": {
"expected_response": "MLflow is a tool for ML",
"max_length": 100,
},
},
{
"inputs": {"question": "What is Spark?"},
"expectations": {
"expected_response": "Spark is a fast data processing engine",
"max_length": 1,
},
},
]
model = TestModel()
predict_fn = mlflow.trace(model.predict) if is_predict_fn_traced else model.predict
result = mlflow.genai.evaluate(
predict_fn=predict_fn,
data=data,
scorers=[exact_match, is_concise, relevance, has_trace],
model_id=model_id,
)
metrics = result.metrics
assert metrics["exact_match/mean"] == 0.0
assert metrics["is_concise/mean"] == 0.5
assert metrics["relevance/mean"] == 1.0
assert metrics["has_trace/mean"] == 1.0
# Metrics should be logged to the model ID as well
model = mlflow.get_logged_model(model_id)
assert metrics == {m.key: m.value for m in model.metrics}
# Exact number of traces should be generated
traces = get_traces()
assert len(traces) == len(data)
# Traces should be associated with the eval run
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == len(data)
# Re-order traces to match with the order of the input data
traces = sorted(traces, key=lambda t: t.data.spans[0].inputs["question"])
# Check if the model_id is set in the traces
assert traces[0].info.trace_metadata[TraceMetadataKey.MODEL_ID] == model_id
assert traces[1].info.trace_metadata[TraceMetadataKey.MODEL_ID] == model_id
# Validate assessments are added to the traces
for i in range(len(traces)):
assert len(traces[i].data.spans) == 1
span = traces[i].data.spans[0]
assert span.name == "predict"
assert span.inputs == data[i]["inputs"]
assert span.outputs == "I don't know"
_validate_assessments(traces)
_validate_eval_result_df(result)
@pytest.mark.parametrize("return_type", ["pandas", "list"])
def test_evaluate_with_traces(monkeypatch: pytest.MonkeyPatch, server_config, return_type):
questions = ["What is MLflow?", "What is Spark?"]
@mlflow.trace(span_type=SpanType.AGENT)
def predict(question: str) -> str:
return TestModel().predict(question)
predict(questions[0])
trace_id = mlflow.get_last_active_trace_id()
mlflow.log_expectation(
trace_id=trace_id,
name="expected_response",
value="MLflow is a tool for ML",
source=AssessmentSource(source_id="me", source_type="HUMAN"),
)
mlflow.log_expectation(
trace_id=trace_id,
name="max_length",
value=100,
source=AssessmentSource(source_id="me", source_type="HUMAN"),
)
predict(questions[1])
trace_id = mlflow.get_last_active_trace_id()
mlflow.log_expectation(
trace_id=trace_id,
name="expected_response",
value="Spark is a fast data processing engine",
source=AssessmentSource(source_id="me", source_type="HUMAN"),
)
mlflow.log_expectation(
trace_id=trace_id,
name="max_length",
value=1,
source=AssessmentSource(source_id="me", source_type="HUMAN"),
)
data = mlflow.search_traces(return_type=return_type)
assert len(data) == len(questions)
result = mlflow.genai.evaluate(
data=data,
scorers=[exact_match, is_concise, relevance, has_trace],
)
metrics = result.metrics
assert metrics["exact_match/mean"] == 0.0
assert metrics["is_concise/mean"] == 0.5
assert metrics["relevance/mean"] == 1.0
assert metrics["has_trace/mean"] == 1.0
if server_config.backend_type == "sqlalchemy":
# Assessments should be added to the traces in-place and no new trace should be created
traces = get_traces()
assert len(traces) == len(questions)
else:
# File store doesn't support trace linking, so each trace will be cloned to the eval run
assert len(get_traces()) == len(questions) * 2
# Traces are associated with the eval run
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == len(questions)
# Re-order traces to match with the order of the input data
traces = sorted(traces, key=lambda t: t.data.spans[0].inputs["question"])
# Validate assessments are added to the traces
_validate_assessments(traces)
_validate_eval_result_df(result)
def test_evaluate_with_managed_dataset(is_in_databricks):
if is_in_databricks:
# Databricks path: Use managed dataset with mocks
class MockDatasetClient:
def __init__(self):
# dataset_id -> list of records
self.records = {}
def create_dataset(self, uc_table_name: str, experiment_ids: list[str]):
from databricks.agents.datasets import Dataset
dataset = Dataset(
dataset_id=str(uuid.uuid4()),
name=uc_table_name,
digest=None,
source_type="databricks-uc-table",
)
self.records[dataset.dataset_id] = []
return dataset
def list_dataset_records(self, dataset_id: str):
return self.records[dataset_id]
def batch_create_dataset_records(self, name: str, dataset_id: str, records):
self.records[dataset_id].extend(records)
def upsert_dataset_record_expectations(
self, name: str, dataset_id: str, record_id: str, expectations: list[dict[str, Any]]
):
for record in self.records[dataset_id]:
if record.id == record_id:
record.expectations.update(expectations)
def sync_dataset_to_uc(self, dataset_id: str, uc_table_name: str):
pass
mock_client = MockDatasetClient()
with (
mock.patch("databricks.rag_eval.datasets.api._get_client", return_value=mock_client),
mock.patch(
"databricks.rag_eval.datasets.entities._get_client", return_value=mock_client
),
mock.patch("mlflow.genai.datasets.is_databricks_uri", return_value=True),
):
dataset = create_dataset(
uc_table_name="mlflow.managed.dataset", experiment_id="exp-123"
)
dataset.merge_records([
{
"inputs": {"question": "What is MLflow?"},
"expectations": {
"expected_response": "MLflow is a tool for ML",
"max_length": 100,
},
},
{
"inputs": {"question": "What is Spark?"},
"expectations": {
"expected_response": "Spark is a fast data processing engine",
"max_length": 1,
},
},
])
result = mlflow.genai.evaluate(
data=dataset,
predict_fn=TestModel().predict,
scorers=[exact_match, is_concise, relevance, has_trace],
)
else:
dataset = create_dataset(
name="eval_test_dataset", tags={"source": "test", "version": "1.0"}
)
dataset.merge_records([
{
"inputs": {"question": "What is MLflow?"},
"expectations": {
"expected_response": "MLflow is a tool for ML",
"max_length": 100,
},
},
{
"inputs": {"question": "What is Spark?"},
"expectations": {
"expected_response": "Spark is a fast data processing engine",
"max_length": 1,
},
},
])
result = mlflow.genai.evaluate(
data=dataset,
predict_fn=TestModel().predict,
scorers=[exact_match, is_concise, relevance, has_trace],
)
metrics = result.metrics
assert metrics["exact_match/mean"] == 0.0
assert metrics["is_concise/mean"] == 0.5
assert metrics["relevance/mean"] == 1.0
assert metrics["has_trace/mean"] == 1.0
run = mlflow.get_run(result.run_id)
# Dataset metadata should be added to the run
assert len(run.inputs.dataset_inputs) == 1
assert run.inputs.dataset_inputs[0].dataset.name == dataset.name
assert run.inputs.dataset_inputs[0].dataset.digest == dataset.digest
# Check for the correct source_type based on whether we're in Databricks or OSS
expected_source_type = (
"databricks-uc-table" if is_in_databricks else "mlflow_evaluation_dataset"
)
assert run.inputs.dataset_inputs[0].dataset.source_type == expected_source_type
# Traces are associated with the eval run
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == 2
_validate_assessments(traces)
_validate_eval_result_df(result)
def test_evaluate_with_managed_dataset_from_searched_traces():
for i in range(3):
with mlflow.start_span(name=f"qa_span_{i}") as span:
question = f"What is item {i}?"
span.set_inputs({"question": question})
span.set_outputs({"answer": f"Item {i} is something"})
mlflow.log_expectation(
trace_id=span.trace_id,
name="expected_response",
value=f"Item {i} is a detailed answer",
)
mlflow.log_expectation(
trace_id=span.trace_id,
name="max_length",
value=50 if i % 2 == 0 else 10,
)
traces_df = mlflow.search_traces()
dataset = create_dataset(
name="traces_eval_dataset", tags={"source": "traces", "evaluation": "test"}
)
dataset.merge_records(traces_df)
result = mlflow.genai.evaluate(
data=dataset,
predict_fn=TestModel().predict,
scorers=[exact_match, is_concise, has_trace],
)
metrics = result.metrics
assert "exact_match/mean" in metrics
assert "is_concise/mean" in metrics
assert "has_trace/mean" in metrics
assert metrics["has_trace/mean"] == 1.0
def test_model_from_deployment_endpoint(is_in_databricks):
with mock.patch("mlflow.deployments.get_deploy_client") as mock_get_deploy_client:
mock_client = mock_get_deploy_client.return_value
mock_client.predict.return_value = _DUMMY_CHAT_RESPONSE
data = [
{
"inputs": {
"messages": [
{"content": "You are a helpful assistant.", "role": "system"},
{"content": "What is Spark?", "role": "user"},
],
"max_tokens": 10,
}
},
{
"inputs": {
"messages": [
{"content": "What is MLflow?", "role": "user"},
]
}
},
]
predict_fn = mlflow.genai.to_predict_fn("endpoints:/chat")
result = mlflow.genai.evaluate(
data=data,
predict_fn=predict_fn,
scorers=[has_trace],
)
databricks_options = {"databricks_options": {"return_trace": True}}
mock_client.predict.assert_has_calls(
[
# Test call to check if the function is traced or not
mock.call(endpoint="chat", inputs={**data[0]["inputs"], **databricks_options}),
# First evaluation call
mock.call(endpoint="chat", inputs={**data[0]["inputs"], **databricks_options}),
# Second evaluation call
mock.call(endpoint="chat", inputs={**data[1]["inputs"], **databricks_options}),
],
any_order=True,
)
# Validate traces
traces = mlflow.search_traces(run_id=result.run_id, return_type="list")
assert len(traces) == 2
spans = traces[0].data.spans
assert len(spans) == 1
assert spans[0].name == "predict"
# Eval harness runs prediction in parallel, so the order is not deterministic
assert spans[0].inputs in (data[0]["inputs"], data[1]["inputs"])
assert spans[0].outputs == _DUMMY_CHAT_RESPONSE
def test_missing_scorers_argument():
with pytest.raises(TypeError, match=r"evaluate\(\) missing 1 required positional"):
mlflow.genai.evaluate(data=[{"inputs": "Hello", "outputs": "Hi"}])
def test_empty_scorers_allowed():
mock_result = EvaluationResult(run_id="test-run", metrics={}, result_df=pd.DataFrame())
data = [{"inputs": {"question": "What is MLflow?"}, "outputs": "MLflow is an ML platform"}]
with mock.patch("mlflow.genai.evaluation.base._run_harness") as mock_evaluate_oss:
mock_evaluate_oss.return_value = (mock_result, {})
result = mlflow.genai.evaluate(data=data, scorers=[])
assert result is mock_result
mock_evaluate_oss.assert_called_once()
@pytest.mark.parametrize("pass_full_dataframe", [True, False])
def test_trace_input_can_contain_string_input(pass_full_dataframe, is_in_databricks):
"""
The `inputs` column must be a dictionary when a static dataset is provided.
However, when a trace is provided, it doesn't need to be validated and the
harness can handle it nicely.
"""
with mlflow.start_span() as span:
span.set_inputs("What is MLflow?")
span.set_outputs("MLflow is a tool for ML")
traces = mlflow.search_traces()
if not pass_full_dataframe:
traces = traces[["trace"]]
# Harness should run without an error
mlflow.genai.evaluate(data=traces, scorers=[RelevanceToQuery()])
def test_max_workers_env_var(monkeypatch):
# Disable rate limits so auto-derivation doesn't override the default
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "0")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SCORER_RATE_LIMIT", "0")
def _validate_max_workers(expected_max_workers):
with mock.patch(
"mlflow.genai.evaluation.harness.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as mock_executor:
mlflow.genai.evaluate(
data=[
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
}
],
scorers=[RelevanceToQuery()],
)
# ThreadPoolExecutor is called twice in OSS (harness + scorers)
first_call = mock_executor.call_args_list[0]
assert first_call[1]["max_workers"] == expected_max_workers
# default workers is 10
_validate_max_workers(10)
# override workers with env var
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_WORKERS", "20")
_validate_max_workers(20)
# legacy env var for backward compatibility
monkeypatch.delenv("MLFLOW_GENAI_EVAL_MAX_WORKERS", raising=False)
monkeypatch.setenv("RAG_EVAL_MAX_WORKERS", "30")
_validate_max_workers(30)
def test_dataset_name_is_logged_correctly(is_in_databricks):
data = pd.DataFrame({
"inputs": [{"question": "What is MLflow?"}],
"outputs": ["MLflow is a tool for ML"],
})
with mlflow.start_run() as run:
mlflow.genai.evaluate(
data=data,
scorers=[RelevanceToQuery()],
)
if not is_in_databricks:
run_data = mlflow.get_run(run.info.run_id)
assert run_data.inputs is not None
assert run_data.inputs.dataset_inputs is not None
assert len(run_data.inputs.dataset_inputs) > 0
dataset_input = run_data.inputs.dataset_inputs[0]
dataset = dataset_input.dataset
assert dataset.name == "dataset"
def test_evaluate_with_dataset_preserves_name(is_in_databricks):
from mlflow.entities import Dataset as DatasetEntity
data = pd.DataFrame({
"inputs": [{"question": "What is MLflow?"}],
"outputs": ["MLflow is a tool for ML"],
})
mock_managed_dataset = MagicMock(spec=EvaluationDataset)
type(mock_managed_dataset).name = mock.PropertyMock(return_value="my_managed_dataset")
mock_managed_dataset.to_df.return_value = data
mock_managed_dataset.digest = "test_digest"
mock_managed_dataset.source = MagicMock()
mock_managed_dataset.source.to_json.return_value = "{}"
mock_managed_dataset.source._get_source_type.return_value = "test"
mock_managed_dataset._to_mlflow_entity.return_value = DatasetEntity(
name="my_managed_dataset",
digest="test_digest",
source_type="test",
source="{}",
schema=None,
profile=None,
)
if not is_in_databricks:
with mlflow.start_run() as run:
mlflow.genai.evaluate(
data=data,
scorers=[RelevanceToQuery()],
)
run_data = mlflow.get_run(run.info.run_id)
dataset_input = run_data.inputs.dataset_inputs[0]
assert dataset_input.dataset.name == "dataset"
with mlflow.start_run() as run:
mlflow.genai.evaluate(
data=mock_managed_dataset,
scorers=[RelevanceToQuery()],
)
run_data = mlflow.get_run(run.info.run_id)
dataset_input = run_data.inputs.dataset_inputs[0]
assert dataset_input.dataset.name == "my_managed_dataset"
def test_evaluate_with_managed_dataset_preserves_name():
mock_managed_dataset = MagicMock()
mock_managed_dataset.dataset_id = "d-1234567890abcdef1234567890abcdef"
mock_managed_dataset.name = "test.evaluation.sample_dataset"
mock_managed_dataset.digest = "abc123"
mock_managed_dataset.schema = None
mock_managed_dataset.profile = None
mock_managed_dataset.source_type = "databricks-uc-table"
mock_managed_dataset.create_time = None
mock_managed_dataset.created_by = None
mock_managed_dataset.last_update_time = None
mock_managed_dataset.last_updated_by = None
mock_managed_dataset.to_df.return_value = pd.DataFrame({
"inputs": [{"question": "What is MLflow?"}],
"outputs": ["MLflow is a tool for ML"],
})
dataset = EvaluationDataset(mock_managed_dataset)
with mlflow.start_run() as run:
mlflow.genai.evaluate(
data=dataset,
scorers=[RelevanceToQuery()],
)
run_data = mlflow.get_run(run.info.run_id)
assert run_data.inputs is not None
assert run_data.inputs.dataset_inputs is not None
assert len(run_data.inputs.dataset_inputs) > 0
dataset_input = run_data.inputs.dataset_inputs[0]
logged_dataset = dataset_input.dataset
assert logged_dataset.name == "test.evaluation.sample_dataset"
@pytest.mark.parametrize(
("tags_data", "expected_calls"),
[
# Regular tags
(
[
{"environment": "test", "model_version": "v1.0"},
{"environment": "production", "team": "data-science"},
],
[
("environment", "test"),
("model_version", "v1.0"),
("environment", "production"),
("team", "data-science"),
],
),
# Empty tags dict
(
[{}, {}],
[],
),
# None tags (no tags field)
(
[None, None],
[],
),
# Mix of tags and empty/None
(
[{"env": "test"}, {}, None],
[("env", "test")],
),
],
)
def test_evaluate_with_tags(tags_data, expected_calls):
data = [
{
"inputs": {"question": f"What is question {i}?"},
"outputs": f"Answer {i}",
"expectations": {"expected_response": f"Answer {i}"},
"tags": tags,
}
for i, tags in enumerate(tags_data)
]
with mock.patch("mlflow.set_trace_tag") as mock_set_trace_tag:
mlflow.genai.evaluate(
data=data,
scorers=[exact_match],
)
# Check that all expected calls were made (order may vary due to parallel execution)
actual_calls = mock_set_trace_tag.call_args_list
expected_mock_calls = [
mock.call(trace_id=ANY, key=key, value=value) for key, value in expected_calls
]
assert len(actual_calls) == len(expected_mock_calls)
for expected_call in expected_mock_calls:
assert expected_call in actual_calls
def test_evaluate_with_traces_tags_no_warnings():
with mlflow.start_span() as span:
span.set_inputs({"question": "Hello?"})
traces = mlflow.search_traces()
with mock.patch("mlflow.tracing.client._logger.warning") as mock_warning:
mlflow.genai.evaluate(
data=traces,
scorers=[has_trace],
)
assert not any(
"immutable and cannot be set on a trace" in call.args[0]
for call in mock_warning.call_args_list
)
def test_evaluate_with_tags_error_handling(is_in_databricks):
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
"expectations": {"expected_response": "MLflow is a tool for ML"},
"tags": {"invalid_tag": "value"},
}
]
# Mock set_trace_tag to raise an exception
with mock.patch("mlflow.set_trace_tag", side_effect=Exception("Tag logging failed")):
# This should not raise an exception
result = mlflow.genai.evaluate(
data=data,
scorers=[exact_match],
)
# Evaluation should still succeed
assert "exact_match/mean" in result.metrics
def test_evaluate_with_invalid_tags_type():
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
"expectations": {"expected_response": "MLflow is a tool for ML"},
"tags": "invalid_tags_string", # Should be dict
}
]
with pytest.raises(MlflowException, match="Tags must be a dictionary"):
mlflow.genai.evaluate(
data=data,
scorers=[exact_match],
)
def test_evaluate_without_inputs_in_eval_dataset():
answers = [
"MLflow is an open-source platform for managing ML lifecycle",
"Apache Spark is a fast data processing engine",
"I don't know",
]
for answer in answers:
with mlflow.start_span() as span:
span.set_outputs(answer)
trace_df = mlflow.search_traces()
trace_df["inputs"] = None
trace_df["expectations"] = pd.Series([
{"expected_response": answer, "max_length": 100} for answer in answers
])
result = mlflow.genai.evaluate(
data=trace_df,
scorers=[is_concise, exact_match, has_trace],
)
assert "is_concise/mean" in result.metrics
assert "exact_match/mean" in result.metrics
assert "has_trace/mean" in result.metrics
@scorer
def input_exist(inputs):
if inputs is None:
return False
return True
trace_df["outputs"] = None
result = mlflow.genai.evaluate(
data=trace_df,
scorers=[input_exist],
)
assert result.metrics["input_exist/mean"] == 0.0
def test_evaluate_with_only_trace_in_eval_dataset():
for _ in range(3):
with mlflow.start_span():
pass
trace_df = mlflow.search_traces()
trace_df = trace_df[["trace"]]
result = mlflow.genai.evaluate(
data=trace_df,
scorers=[has_trace],
)
assert result.metrics["has_trace/mean"] == 1.0
@pytest.mark.parametrize("is_enabled", [True, False])
def test_evaluate_with_scorer_tracing(server_config, monkeypatch, is_enabled):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_ENABLE_SCORER_TRACING", str(is_enabled).lower())
data = [
{
"inputs": {"question": "What is MLflow?"},
"expectations": {
"expected_response": "MLflow is a tool for ML",
"max_length": 100,
},
},
{
"inputs": {"question": "What is Spark?"},
"expectations": {
"expected_response": "Spark is a fast data processing engine",
"max_length": 1,
},
},
]
result = mlflow.genai.evaluate(
predict_fn=TestModel().predict,
data=data,
scorers=[exact_match, is_concise, relevance, has_trace],
)
metrics = result.metrics
assert metrics["exact_match/mean"] == 0.0
assert metrics["is_concise/mean"] == 0.5
assert metrics["relevance/mean"] == 1.0
assert metrics["has_trace/mean"] == 1.0
traces = get_traces()
if is_enabled:
assert len(traces) == len(data) * 5 # 1 trace for prediction + 4 scorer traces
else:
assert len(traces) == len(data)
# Traces should be associated with the eval run
traces = mlflow.search_traces(
filter_string="tags.`mlflow.eval.requestId` != 'None'",
run_id=result.run_id,
return_type="list",
)
assert len(traces) == len(data)
# Each assessment should have a source trace ID
for trace in traces:
for a in trace.info.assessments:
if isinstance(a, Feedback) and is_enabled:
assert a.metadata[AssessmentMetadataKey.SCORER_TRACE_ID] is not None
assert a.metadata[AssessmentMetadataKey.SCORER_TRACE_ID] != trace.info.trace_id
else:
assert AssessmentMetadataKey.SCORER_TRACE_ID not in a.metadata
@pytest.mark.parametrize("diff_experiment_id", [True, False])
def test_eval_with_traces_log_spans_correctly(diff_experiment_id):
exp_id = mlflow.set_experiment("traces exp").experiment_id
with mlflow.start_span() as span:
span.set_inputs({"question": "What is MLflow?"})
span.set_outputs({"answer": "MLflow is a tool for ML"})
span.set_attributes({"key": "value"})
with mlflow.start_span() as child_span:
child_span.set_inputs("test")
# set to a different experiment
if diff_experiment_id:
mlflow.set_experiment("diff exp")
# search traces from the original experiment
trace_df = mlflow.search_traces(locations=[exp_id])
result = mlflow.genai.evaluate(
data=trace_df,
scorers=[has_trace],
)
assert result.metrics["has_trace/mean"] == 1.0
traces = get_traces()
assert len(traces) == 1
# copied trace should contain all spans
assert len(traces[0].data.spans) == 2
span = traces[0].data.spans[0]
assert span.get_attribute("key") == "value"
assert span.inputs == {"question": "What is MLflow?"}
assert span.outputs == {"answer": "MLflow is a tool for ML"}
child_span = traces[0].data.spans[1]
assert child_span.inputs == "test"
def test_evaluate_with_mixed_single_turn_and_multi_turn_scorers(server_config):
"""Test evaluation with a combination of single-turn and multi-turn scorers.
Validates that:
- Single-turn scorers are applied to all traces
- Multi-turn scorers are only applied to the first trace of each session
"""
# Define a multi-turn scorer that counts conversation turns
class ConversationLengthScorer(mlflow.genai.Scorer):
def __init__(self):
super().__init__(name="conversation_length")
@property
def is_session_level_scorer(self) -> bool:
return True
def __call__(self, session=None, **kwargs):
"""Return the number of turns in the conversation."""
return len(session or [])
# Define a single-turn scorer
@scorer
def response_length(outputs) -> int:
"""Return the length of the response."""
return len(outputs) if isinstance(outputs, str) else 0
# Create a traced model function
@mlflow.trace(span_type=SpanType.CHAT_MODEL)
def model(question, session_id):
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
return f"Answer to: {question}"
# Generate traces for 2 sessions (3 turns + 2 turns = 5 total traces)
mlflow.set_experiment("multi_turn_test")
with mlflow.start_run() as run:
# Session 1: 3 turns
for q in ["Q1", "Q2", "Q3"]:
model(q, session_id="session_1")
# Session 2: 2 turns
for q in ["Q4", "Q5"]:
model(q, session_id="session_2")
# Get traces for evaluation
traces = mlflow.search_traces(
locations=[run.info.experiment_id], filter_string=f'run_id = "{run.info.run_id}"'
)
# Evaluate with both single-turn and multi-turn scorers
result = mlflow.genai.evaluate(
data=traces, scorers=[response_length, ConversationLengthScorer()]
)
# Validate results
result_df = result.result_df
# Should have one row per trace
assert len(result_df) == 5, f"Expected 5 traces, got {len(result_df)}"
# Single-turn scorer should be applied to all traces
single_turn_scores = result_df["response_length/value"].notna().sum()
assert single_turn_scores == 5, (
f"Expected single-turn scores for all 5 traces, got {single_turn_scores}"
)
# Multi-turn scorer should only be applied to first trace of each session (2 total)
multi_turn_scores = result_df["conversation_length/value"].notna().sum()
assert multi_turn_scores == 2, (
f"Expected multi-turn scores for 2 sessions (first trace only), got {multi_turn_scores}"
)
# Validate the conversation length values
# Session 1 should have 3 turns, Session 2 should have 2 turns
conv_lengths = result_df["conversation_length/value"].dropna().sort_values().tolist()
assert conv_lengths == [2.0, 3.0], (
f"Expected conversation lengths [2.0, 3.0], got {conv_lengths}"
)
# Validate that all single-turn scores are the same (based on our dummy response)
response_lengths = result_df["response_length/value"].dropna()
# All responses should be "Answer to: Qx" format, so lengths should be consistent
assert all(length > 0 for length in response_lengths)
# Verify multi-turn assessments were persisted to the backend (not just in-memory).
# Fetch each trace from the backend and check the first trace of each session has
# the conversation_length assessment logged.
all_traces = mlflow.search_traces(
locations=[run.info.experiment_id],
run_id=result.run_id,
return_type="list",
)
for trace in all_traces:
assessment_names = {a.name for a in trace.info.assessments}
has_session = "mlflow.trace.session" in trace.info.trace_metadata
is_first_in_session = "conversation_length" in assessment_names
if has_session and is_first_in_session:
a = next(a for a in trace.info.assessments if a.name == "conversation_length")
assert isinstance(a, Feedback)
assert a.value in (2.0, 3.0)
def test_evaluate_with_evaluation_dataset_and_session_level_scorers():
# Define a session-level scorer
class ConversationLengthScorer(mlflow.genai.Scorer):
def __init__(self):
super().__init__(name="conversation_length")
@property
def is_session_level_scorer(self) -> bool:
return True
def __call__(self, session=None, **kwargs):
return len(session or [])
# Create traces with session metadata (2 traces in session_1, 1 in session_2)
@mlflow.trace(span_type=mlflow.entities.SpanType.CHAT_MODEL)
def model(question, session_id):
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
return f"Answer to {question}"
model("Q1", session_id="session_1")
trace_1 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q2", session_id="session_1")
trace_2 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q3", session_id="session_2")
trace_3 = mlflow.get_trace(mlflow.get_last_active_trace_id())
# Create dataset from traces
dataset = create_dataset(name="multi_turn_dataset")
dataset.merge_records([trace_1, trace_2, trace_3])
# Evaluate with session-level scorer
result = mlflow.genai.evaluate(data=dataset, scorers=[ConversationLengthScorer()])
result_df = result.result_df
# Session-level scorer should produce 2 scores (one per session)
assert "conversation_length/value" in result_df.columns
assert result_df["conversation_length/value"].notna().sum() == 2
# Verify conversation lengths: session_1 has 2 traces, session_2 has 1 trace
conv_lengths = result_df["conversation_length/value"].dropna().sort_values().tolist()
assert conv_lengths == [1.0, 2.0]
def test_evaluate_dataset_mixed_traces_with_and_without_sessions():
class SessionScorer(mlflow.genai.Scorer):
def __init__(self):
super().__init__(name="session_length")
@property
def is_session_level_scorer(self):
return True
def __call__(self, session=None, **kwargs):
return len(session or [])
# Create mixed traces
@mlflow.trace(span_type=mlflow.entities.SpanType.CHAT_MODEL)
def model_with_session(question, session_id):
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
return "answer"
@mlflow.trace(span_type=mlflow.entities.SpanType.CHAT_MODEL)
def model_without_session(question):
return "answer"
model_with_session("Q1", "session_1")
trace_1 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model_without_session("Q2")
trace_2 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model_with_session("Q3", "session_1")
trace_3 = mlflow.get_trace(mlflow.get_last_active_trace_id())
# Create dataset and evaluate
dataset = create_dataset(name="mixed_dataset")
dataset.merge_records([trace_1, trace_2, trace_3])
result = mlflow.genai.evaluate(data=dataset, scorers=[SessionScorer()])
result_df = result.result_df
# Should have 1 session-level score (for session_1 with 2 traces)
# The trace without session should not be scored by session-level scorer
assert result_df["session_length/value"].notna().sum() == 1
assert result_df["session_length/value"].dropna().iloc[0] == 2.0
def test_max_scorer_workers_env_var(monkeypatch):
@scorer
def dummy_scorer_1(outputs):
return True
@scorer
def dummy_scorer_2(outputs):
return True
@scorer
def dummy_scorer_3(outputs):
return True
def _validate_scorer_max_workers(expected_max_workers, num_scorers):
scorers_list = [dummy_scorer_1, dummy_scorer_2, dummy_scorer_3][:num_scorers]
with mock.patch(
"mlflow.genai.evaluation.harness.ThreadPoolExecutor", wraps=ThreadPoolExecutor
) as mock_executor:
mlflow.genai.evaluate(
data=[
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
}
],
scorers=scorers_list,
)
# Find the scorer pool call by its thread_name_prefix
scorer_call = next(
call
for call in mock_executor.call_args_list
if call[1].get("thread_name_prefix") == "MlflowGenAIEvalScorer"
)
assert scorer_call[1]["max_workers"] == expected_max_workers
# default scorer workers is 10, but limited by number of scorers (3)
_validate_scorer_max_workers(expected_max_workers=3, num_scorers=3)
# override scorer workers with env var (limit to 2)
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_SCORER_WORKERS", "2")
_validate_scorer_max_workers(expected_max_workers=2, num_scorers=3)
# when num_scorers < max_scorer_workers, use num_scorers
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_SCORER_WORKERS", "10")
_validate_scorer_max_workers(expected_max_workers=2, num_scorers=2)
# set to 1 for sequential execution
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_SCORER_WORKERS", "1")
_validate_scorer_max_workers(expected_max_workers=1, num_scorers=3)
# ===================== ConversationSimulator Integration Tests =====================
def test_evaluate_with_conversation_simulator_requires_predict_fn():
simulator = ConversationSimulator(
test_cases=[{"goal": "Learn about MLflow"}],
max_turns=2,
)
with pytest.raises(MlflowException, match="predict_fn is required"):
mlflow.genai.evaluate(
data=simulator,
scorers=[has_trace],
)
def test_evaluate_with_conversation_simulator_empty_simulation_error():
def failing_predict_fn(input: list[dict[str, Any]], **kwargs):
raise Exception("Simulated failure")
simulator = ConversationSimulator(
test_cases=[{"goal": "Learn about MLflow"}],
max_turns=2,
)
with mock.patch(
"mlflow.genai.simulators.simulator.invoke_model_without_tracing"
) as mock_invoke:
# Simulate a failure that produces no traces
mock_invoke.side_effect = Exception("LLM call failed")
with pytest.raises(MlflowException, match="Simulation produced no traces"):
mlflow.genai.evaluate(
data=simulator,
predict_fn=failing_predict_fn,
scorers=[has_trace],
)
def test_session_level_evaluation_with_predict_fn_without_simulator():
class SessionScorer(mlflow.genai.Scorer):
def __init__(self):
super().__init__(name="session_scorer")
@property
def is_session_level_scorer(self):
return True
def __call__(self, session=None, **kwargs):
return len(session or [])
data = [
{"inputs": {"question": "What is MLflow?"}, "outputs": "MLflow is a tool"},
]
with pytest.raises(
MlflowException,
match=(
r"Session-level scorers require traces with session IDs.*"
r"session_scorer.*"
r"Either pass a ConversationSimulator to `data` with `predict_fn`"
),
):
mlflow.genai.evaluate(
data=data,
predict_fn=TestModel().predict,
scorers=[SessionScorer()],
)
def test_evaluate_with_conversation_simulator_calls_simulate():
simulator = ConversationSimulator(
test_cases=[{"goal": "Learn MLflow"}],
max_turns=2,
)
def mock_predict_fn(input: list[dict[str, Any]], **kwargs):
return {"output": "Mock response"}
with mock.patch.object(simulator, "simulate") as mock_simulate:
# Return empty list to trigger the "no traces" error
mock_simulate.return_value = []
with pytest.raises(MlflowException, match="Simulation produced no traces"):
mlflow.genai.evaluate(
data=simulator,
predict_fn=mock_predict_fn,
scorers=[has_trace],
)
# Verify simulate was called with predict_fn
mock_simulate.assert_called_once_with(mock_predict_fn)
@scorer
def always_pass(outputs):
return True
@pytest.mark.parametrize(
("expectations_values", "expected_count"),
[
([None, {}], 2),
([float("nan")], 1),
([{"expected": "value"}, None, {}], 3),
],
)
def test_evaluate_handles_empty_expectations(expectations_values, expected_count):
data = [
{
"inputs": {"question": f"Q{i}"},
"outputs": f"A{i}",
"expectations": exp,
}
for i, exp in enumerate(expectations_values)
]
result = mlflow.genai.evaluate(data=data, scorers=[always_pass])
assert result is not None
assert result.metrics is not None
assert result.result_df is not None
assert len(result.result_df) == expected_count
assert result.metrics["always_pass/mean"] == 1.0
from mlflow.genai.simulators.simulator import BaseSimulatedUserAgent
class MockUserAgent(BaseSimulatedUserAgent):
def __init__(self, **kwargs):
pass
def generate_message(self, context):
if context.turn == 0:
return f"Hello, I want to {context.goal}"
return "[GOAL ACHIEVED]"
def test_evaluate_with_simulator_creates_single_run(tmp_path):
mlflow.set_tracking_uri(f"sqlite:///{tmp_path}/mlflow.db")
mlflow.set_experiment("test-experiment")
simulator = ConversationSimulator(
test_cases=[{"goal": "get help"}],
max_turns=2,
user_agent_class=MockUserAgent,
)
def mock_predict_fn(input: list[dict[str, Any]], **kwargs):
return {"content": "Response"}
with mock.patch(
"mlflow.genai.simulators.simulator.invoke_model_without_tracing",
return_value='{"rationale": "Goal achieved!", "result": "yes"}',
):
mlflow.genai.evaluate(data=simulator, predict_fn=mock_predict_fn, scorers=[])
runs = mlflow.search_runs()
assert len(runs) == 1
def test_evaluate_with_simulator_within_parent_run(tmp_path):
mlflow.set_tracking_uri(f"sqlite:///{tmp_path}/mlflow.db")
mlflow.set_experiment("test-experiment")
simulator = ConversationSimulator(
test_cases=[{"goal": "get help"}],
max_turns=2,
user_agent_class=MockUserAgent,
)
def mock_predict_fn(input: list[dict[str, Any]], **kwargs):
return {"content": "Response"}
with mock.patch(
"mlflow.genai.simulators.simulator.invoke_model_without_tracing",
return_value='{"rationale": "Goal achieved!", "result": "yes"}',
):
with mlflow.start_run(run_name="parent-run") as parent_run:
parent_run_id = parent_run.info.run_id
mlflow.genai.evaluate(data=simulator, predict_fn=mock_predict_fn, scorers=[])
assert mlflow.active_run().info.run_id == parent_run_id
runs = mlflow.search_runs()
assert len(runs) == 1
assert runs.iloc[0]["tags.mlflow.runName"] == "parent-run"
# ===================== Rate Limiting & Pipelining Tests =====================
def test_predict_rate_limiter_is_wired_to_predict_fn(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "100")
with mock.patch.object(
RPSRateLimiter, "acquire", autospec=True, side_effect=lambda self: None
) as mock_acquire:
data = [{"inputs": {"q": f"Q{i}"}} for i in range(5)]
mlflow.genai.evaluate(data=data, predict_fn=lambda q: "answer", scorers=[])
assert mock_acquire.call_count == 5
def test_scorer_rate_limiter_is_wired_to_scorers(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SCORER_RATE_LIMIT", "100")
with mock.patch.object(
RPSRateLimiter, "acquire", autospec=True, side_effect=lambda self: None
) as mock_acquire:
data = [{"inputs": {"q": f"Q{i}"}, "outputs": "a"} for i in range(3)]
mlflow.genai.evaluate(data=data, scorers=[always_pass, always_pass])
# 3 items x 2 scorers = 6 scorer acquire calls (no predict_fn → no predict acquires)
assert mock_acquire.call_count == 6
def test_pipelining_scores_while_predicts_pending(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_WORKERS", "2")
# Gate that blocks predicts after the first batch completes
gate = threading.Event()
predict_call_count = 0
predict_lock = threading.Lock()
scoring_started_while_predicts_pending = threading.Event()
def gated_predict_fn(q):
nonlocal predict_call_count
with predict_lock:
predict_call_count += 1
call_num = predict_call_count
# Let the first 2 predictions through immediately, block the rest
if call_num > 2:
gate.wait(timeout=5)
return "answer"
@scorer
def signaling_scorer(outputs):
# If we're scoring while predicts are still pending, signal success
with predict_lock:
current_predicts = predict_call_count
if (
current_predicts > 0 and current_predicts < 6
): # 6 total items, so some must still be pending
scoring_started_while_predicts_pending.set()
return True
data = [{"inputs": {"q": f"Q{i}"}} for i in range(6)]
try:
# Run evaluate in a background thread so we can release the gate
result_holder = []
def run_eval():
result = mlflow.genai.evaluate(
data=data, predict_fn=gated_predict_fn, scorers=[signaling_scorer]
)
result_holder.append(result)
eval_thread = threading.Thread(name="test-evaluation-eval", target=run_eval)
eval_thread.start()
# Wait for scoring to signal it started while predicts are pending
signaled = scoring_started_while_predicts_pending.wait(timeout=10)
# Release all blocked predicts
gate.set()
eval_thread.join(timeout=30)
assert signaled
finally:
gate.set() # Ensure we don't leave threads blocked
def test_backpressure_limits_in_flight_items(monkeypatch):
workers = 2
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_WORKERS", str(workers))
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "0")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SCORER_RATE_LIMIT", "0")
# Skip pre-flight predict_fn call that runs outside the backpressure semaphore
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SKIP_TRACE_VALIDATION", "true")
buffer = backpressure_buffer(workers)
max_in_flight = 0
in_flight = 0
lock = threading.Lock()
predict_done = threading.Semaphore(0)
score_gate = threading.Event()
def tracking_predict(q):
nonlocal in_flight, max_in_flight
with lock:
in_flight += 1
max_in_flight = max(max_in_flight, in_flight)
predict_done.release()
return "answer"
@scorer
def blocking_scorer(outputs):
score_gate.wait()
nonlocal in_flight
with lock:
in_flight -= 1
return True
num_items = 3 * buffer
data = [{"inputs": {"q": f"Q{i}"}} for i in range(num_items)]
eval_thread = threading.Thread(
name="test-evaluation-blocking",
target=lambda: mlflow.genai.evaluate(
data=data, predict_fn=tracking_predict, scorers=[blocking_scorer]
),
)
try:
eval_thread.start()
# Wait for exactly `buffer` predicts to complete. The semaphore guarantees
# each acquire() returns once a predict runs. After this, the submit thread
# is blocked on the backpressure semaphore and no more predicts can run
# (scorers are blocked on score_gate).
for _ in range(buffer):
predict_done.acquire()
with lock:
observed_max = max_in_flight
finally:
score_gate.set()
eval_thread.join()
# The semaphore bounds in-flight items. Without backpressure all
# num_items would pile up.
assert observed_max == buffer
def test_evaluate_logs_scorer_failure_summary():
@scorer
def failing_scorer(inputs, outputs):
raise ValueError("Model endpoint not found")
@scorer
def working_scorer(inputs, outputs):
return True
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a platform",
},
{
"inputs": {"question": "What is Python?"},
"outputs": "Python is a language",
},
]
with mock.patch("mlflow.genai.evaluation.harness._logger.warning") as mock_warning:
result = mlflow.genai.evaluate(
data=data,
scorers=[failing_scorer, working_scorer],
)
# Evaluation should complete without raising an exception
assert "working_scorer/mean" in result.metrics
# Verify warning was logged with failure summary
warning_calls = [call.args[0] for call in mock_warning.call_args_list]
failure_warnings = [
msg
for msg in warning_calls
if "Some scorer invocations failed during evaluation" in msg
]
warning_message = failure_warnings[0]
assert "'failing_scorer': 2/2 failed" in warning_message
assert "Check individual trace assessments for detailed error messages" in warning_message
def test_evaluate_no_warning_when_all_scorers_succeed():
@scorer
def working_scorer_1(inputs, outputs):
return True
@scorer
def working_scorer_2(inputs, outputs):
return False
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a platform",
},
]
with mock.patch("mlflow.genai.evaluation.harness._logger.warning") as mock_warning:
result = mlflow.genai.evaluate(
data=data,
scorers=[working_scorer_1, working_scorer_2],
)
assert "working_scorer_1/mean" in result.metrics
assert "working_scorer_2/mean" in result.metrics
warning_calls = [call.args[0] for call in mock_warning.call_args_list]
assert not any(
"Some scorer invocations failed during evaluation" in msg for msg in warning_calls
)
def test_evaluate_logs_scorer_failure_summary_with_multi_turn_scorers():
@mlflow.trace
def model(question, session_id):
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
return f"Answer to {question}"
model("Q1", session_id="session_1")
trace_1 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q2", session_id="session_1")
trace_2 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q3", session_id="session_2")
trace_3 = mlflow.get_trace(mlflow.get_last_active_trace_id())
dataset = create_dataset(name="multi_turn_failure_test")
dataset.merge_records([trace_1, trace_2, trace_3])
with mock.patch("mlflow.genai.evaluation.harness._logger.warning") as mock_warning:
result = mlflow.genai.evaluate(
data=dataset,
scorers=[FailingSessionScorer(), WorkingSessionScorer()],
)
assert "working_session_scorer/mean" in result.metrics
warning_calls = [call.args[0] for call in mock_warning.call_args_list]
failure_warnings = [
msg
for msg in warning_calls
if "Some scorer invocations failed during evaluation" in msg
]
assert len(failure_warnings) > 0
warning_message = failure_warnings[0]
assert "'failing_session_scorer': 2/2 failed" in warning_message
assert "Check individual trace assessments for detailed error messages" in warning_message
def test_evaluate_logs_scorer_failure_summary_with_mixed_scorers():
@scorer
def failing_single_turn(inputs, outputs):
raise ValueError("Single turn scorer error")
@mlflow.trace
def model(question, session_id):
mlflow.update_current_trace(metadata={"mlflow.trace.session": session_id})
return f"Answer to {question}"
model("Q1", session_id="session_1")
trace_1 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q2", session_id="session_1")
trace_2 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q3", session_id="session_2")
trace_3 = mlflow.get_trace(mlflow.get_last_active_trace_id())
model("Q4", session_id="session_2")
trace_4 = mlflow.get_trace(mlflow.get_last_active_trace_id())
# Create dataset from traces
dataset = create_dataset(name="mixed_scorer_failure_test")
dataset.merge_records([trace_1, trace_2, trace_3, trace_4])
with mock.patch("mlflow.genai.evaluation.harness._logger.warning") as mock_warning:
result = mlflow.genai.evaluate(
data=dataset,
scorers=[
failing_single_turn,
always_pass,
FailingSessionScorer(),
WorkingSessionScorer(),
],
)
assert "always_pass/mean" in result.metrics
assert "working_session_scorer/mean" in result.metrics
warning_calls = [call.args[0] for call in mock_warning.call_args_list]
failure_warnings = [
msg
for msg in warning_calls
if "Some scorer invocations failed during evaluation" in msg
]
assert len(failure_warnings) > 0
warning_message = failure_warnings[0]
assert "'failing_single_turn': 4/4 failed" in warning_message
assert "'failing_session_scorer': 2/2 failed" in warning_message
assert "Check individual trace assessments for detailed error messages" in warning_message
def test_no_rate_limit_backward_compat(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "0")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SCORER_RATE_LIMIT", "0")
data = [
{
"inputs": {"question": "What is MLflow?"},
"outputs": "MLflow is a tool for ML",
"expectations": {"expected_response": "MLflow is a tool for ML", "max_length": 100},
},
]
result = mlflow.genai.evaluate(data=data, scorers=[exact_match, is_concise])
assert result.metrics["exact_match/mean"] == 1.0
assert result.metrics["is_concise/mean"] == 1.0
# ===================== Retry & Adaptive Rate Limiting Tests =====================
def test_predict_retries_on_429(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "0")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_RETRIES", "3")
attempts = []
def flaky_predict(q):
attempts.append(1)
# First call is the pre-flight validation check — let it pass.
# Fail on calls 2 and 3, succeed on call 4.
if 1 < len(attempts) < 4:
raise Exception("429 Too Many Requests")
return "answer"
data = [{"inputs": {"q": "Q1"}}]
result = mlflow.genai.evaluate(data=data, predict_fn=flaky_predict, scorers=[always_pass])
# 1 validation + 1 fail + 1 fail + 1 success = 4 total
assert len(attempts) == 4
assert result.metrics["always_pass/mean"] == 1.0
def test_scorer_retries_on_429(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "0")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_SCORER_RATE_LIMIT", "0")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_RETRIES", "3")
attempts = []
@scorer
def flaky_scorer(outputs):
attempts.append(1)
if len(attempts) < 3:
raise Exception("429 Too Many Requests")
return True
data = [{"inputs": {"q": "Q1"}, "outputs": "a"}]
result = mlflow.genai.evaluate(data=data, scorers=[flaky_scorer])
assert len(attempts) == 3
assert result.metrics["flaky_scorer/mean"] == 1.0
def test_adaptive_rate_reduces_on_429(monkeypatch):
monkeypatch.setenv("MLFLOW_GENAI_EVAL_PREDICT_RATE_LIMIT", "auto")
monkeypatch.setenv("MLFLOW_GENAI_EVAL_MAX_RETRIES", "3")
rate_after_throttle = []
original_report_throttle = RPSRateLimiter.report_throttle
def spy_report_throttle(self):
original_report_throttle(self)
rate_after_throttle.append(self._rps)
attempts = []
def flaky_predict(q):
attempts.append(1)
# First call is the pre-flight validation check — let it pass.
# Fail on call 2 to trigger a throttle event.
if len(attempts) == 2:
raise Exception("429 Too Many Requests")
return "answer"
data = [{"inputs": {"q": f"Q{i}"}} for i in range(3)]
with mock.patch.object(RPSRateLimiter, "report_throttle", spy_report_throttle):
result = mlflow.genai.evaluate(data=data, predict_fn=flaky_predict, scorers=[always_pass])
assert result.metrics["always_pass/mean"] == 1.0
# At least one throttle event observed, and the rate was reduced
assert len(rate_after_throttle) >= 1
assert rate_after_throttle[0] < AUTO_INITIAL_RPS
@pytest.mark.parametrize(
("trace_or_none", "run_id"),
[
(None, "run-1"),
(
Trace(
info=create_test_trace_info_with_uc_table(
trace_id="tr-uc", catalog_name="catalog", schema_name="schema"
),
data=TraceData(spans=[]),
),
"run-1",
),
],
ids=["none_trace", "uc_schema_trace"],
)
def test_should_clone_trace_returns_false_early(trace_or_none, run_id):
assert _should_clone_trace(trace_or_none, run_id=run_id) is False
@pytest.mark.parametrize(
("experiment_id", "expected"),
[
("exp-999", True),
("exp-123", False),
],
ids=["different_experiment", "matching_experiment"],
)
def test_should_clone_trace_with_explicit_experiment_id(
mlflow_experiment_trace, experiment_id, expected
):
with mock.patch(
"mlflow.genai.evaluation.harness._does_store_support_trace_linking",
return_value=True,
) as mock_store:
result = _should_clone_trace(
mlflow_experiment_trace, run_id="run-1", experiment_id=experiment_id
)
assert result is expected
if expected is False:
mock_store.assert_called_once()
def test_should_clone_trace_falls_back_to_get_experiment_id_when_none(mlflow_experiment_trace):
with (
mock.patch(
"mlflow.tracking.fluent._get_experiment_id",
return_value="exp-999",
) as mock_get_exp,
mock.patch(
"mlflow.genai.evaluation.harness._does_store_support_trace_linking",
return_value=True,
),
):
result = _should_clone_trace(mlflow_experiment_trace, run_id="run-1", experiment_id=None)
mock_get_exp.assert_called_once()
assert result is True
def test_should_clone_trace_does_not_call_get_experiment_id_when_provided(mlflow_experiment_trace):
with (
mock.patch("mlflow.tracking.fluent._get_experiment_id") as mock_get_exp,
mock.patch(
"mlflow.genai.evaluation.harness._does_store_support_trace_linking",
return_value=True,
),
):
_should_clone_trace(mlflow_experiment_trace, run_id="run-1", experiment_id="exp-123")
mock_get_exp.assert_not_called()
def _make_eval_item(trace=None, expectations=None):
return EvalItem(
request_id="req-1",
inputs={"question": "q"},
outputs="a",
expectations=expectations or {},
trace=trace,
)
def test_get_new_expectations_returns_empty_when_trace_is_none():
eval_item = _make_eval_item(trace=None, expectations={"expected": "value"})
assert _get_new_expectations(eval_item) == []
def test_get_new_expectations_filters_existing(mlflow_experiment_trace):
eval_item = _make_eval_item(trace=mlflow_experiment_trace, expectations={"correctness": "yes"})
with mock.patch(
"mlflow.genai.evaluation.entities.get_context",
return_value=mock.Mock(**{"get_user_name.return_value": "tester"}),
):
result = _get_new_expectations(eval_item)
assert [e.name for e in result] == ["correctness"]
def test_run_predict_clone_read_miss_records_error_and_nulls_trace(mlflow_experiment_trace):
eval_item = _make_eval_item(trace=mlflow_experiment_trace)
with (
mock.patch(
"mlflow.genai.evaluation.harness._should_clone_trace",
return_value=True,
) as mock_should_clone,
mock.patch(
"mlflow.genai.evaluation.harness.copy_trace_to_experiment",
return_value="tr-cloned",
) as mock_copy,
mock.patch("mlflow.get_trace", return_value=None) as mock_get_trace,
):
_run_predict(
eval_item,
predict_fn=None,
run_id=None,
rate_limiter=NoOpRateLimiter(),
experiment_id="exp-999",
)
mock_should_clone.assert_called_once()
mock_copy.assert_called_once()
mock_get_trace.assert_called_once_with("tr-cloned", flush=True)
assert eval_item.trace is None
assert "could not be read back" in eval_item.error_message
def test_run_predict_clone_read_hit_sets_trace_without_error(mlflow_experiment_trace):
eval_item = _make_eval_item(trace=mlflow_experiment_trace)
cloned = Trace(
info=create_test_trace_info(trace_id="tr-cloned", experiment_id="exp-999"),
data=TraceData(spans=[]),
)
with (
mock.patch(
"mlflow.genai.evaluation.harness._should_clone_trace",
return_value=True,
) as mock_should_clone,
mock.patch(
"mlflow.genai.evaluation.harness.copy_trace_to_experiment",
return_value="tr-cloned",
) as mock_copy,
mock.patch("mlflow.get_trace", return_value=cloned) as mock_get_trace,
):
_run_predict(
eval_item,
predict_fn=None,
run_id=None,
rate_limiter=NoOpRateLimiter(),
experiment_id="exp-999",
)
mock_should_clone.assert_called_once()
mock_copy.assert_called_once()
mock_get_trace.assert_called_once_with("tr-cloned", flush=True)
assert eval_item.trace is cloned
assert eval_item.error_message is None
def test_evaluate_completes_when_cloned_trace_read_back_misses():
# Reproduce #24355: a real cross-experiment clone (searched traces live in a different
# experiment than the eval run), where the cloned trace's async read-back misses and
# returns None. evaluate() must complete without the AttributeError instead of crashing.
exp_id = mlflow.set_experiment("traces exp").experiment_id
with mlflow.start_span(name="qa") as span:
span.set_inputs({"question": "What is MLflow?"})
span.set_outputs("MLflow is a tool for ML")
mlflow.set_experiment("diff exp")
trace_df = mlflow.search_traces(locations=[exp_id])
real_get_trace = mlflow.get_trace
first_call = {"seen": False}
def fake_get_trace(trace_id, *args, **kwargs):
# The first get_trace in the pipeline is the clone read-back in _run_predict; null it
# to simulate the read-after-write miss. Later calls pass through unchanged.
if not first_call["seen"]:
first_call["seen"] = True
return None
return real_get_trace(trace_id, *args, **kwargs)
with mock.patch("mlflow.get_trace", side_effect=fake_get_trace) as mock_get_trace:
result = mlflow.genai.evaluate(data=trace_df, scorers=[has_trace])
mock_get_trace.assert_any_call(mock.ANY, flush=True)
assert result.metrics is not None
def _make_score_submitter(session_groups):
return _ScoreSubmitter(
eval_items=[item for items in session_groups.values() for item in items],
single_turn_scorers=[],
multi_turn_scorers=[mock.Mock()],
session_groups=session_groups,
run_id=None,
max_retries=0,
rps=None,
adaptive=False,
max_rps_multiplier=1.0,
pool_workers=1,
)
def test_run_multi_turn_skips_session_with_only_none_traces():
submitter = _make_score_submitter({"session-1": [_make_eval_item(trace=None)]})
multi_turn_eval_results: dict[str, EvalResult] = {}
with mock.patch(
"mlflow.genai.evaluation.harness.evaluate_session_level_scorers",
) as mock_eval_session:
submitter.run_multi_turn(multi_turn_eval_results, progress_bar=None)
mock_eval_session.assert_not_called()
assert multi_turn_eval_results == {}
def test_run_multi_turn_filters_none_trace_items_from_session(mlflow_experiment_trace):
valid_item = _make_eval_item(trace=mlflow_experiment_trace)
none_item = _make_eval_item(trace=None)
submitter = _make_score_submitter({"session-1": [none_item, valid_item]})
multi_turn_eval_results: dict[str, EvalResult] = {}
with mock.patch(
"mlflow.genai.evaluation.harness.evaluate_session_level_scorers",
return_value=EvalResult(eval_item=valid_item),
) as mock_eval_session:
submitter.run_multi_turn(multi_turn_eval_results, progress_bar=None)
mock_eval_session.assert_called_once()
assert mock_eval_session.call_args.kwargs["session_items"] == [valid_item]
assert multi_turn_eval_results == {"tr-123": mock_eval_session.return_value}