From c1c0be87d8cca054a71758f7ac11d1961dddce6e Mon Sep 17 00:00:00 2001 From: poshinchen Date: Wed, 3 Jun 2026 14:40:36 -0400 Subject: [PATCH] chore(report): always return flattened report --- AGENTS.md | 2 +- README.md | 33 +- SKILL.md | 14 +- src/strands_evals/chaos/experiment.py | 18 +- src/strands_evals/experiment.py | 20 +- .../experimental/redteam/experiment.py | 4 +- .../experimental/redteam/report.py | 44 +-- src/strands_evals/types/evaluation_report.py | 16 +- tests/strands_evals/chaos/test_experiment.py | 8 +- .../experimental/redteam/test_report.py | 36 +- .../test_langchain_mapper_integration.py | 41 +-- tests/strands_evals/test_eval_task.py | 4 +- tests/strands_evals/test_experiment.py | 327 ++++++++---------- .../types/test_evaluation_report.py | 232 +++---------- tests/test_integration.py | 48 +-- tests_integ/test_cloudwatch_provider.py | 24 +- .../test_langchain_openinference_eval.py | 18 +- tests_integ/test_langchain_traceloop_eval.py | 18 +- tests_integ/test_langfuse_provider.py | 35 +- .../test_multimodal_output_evaluator.py | 50 +-- tests_integ/test_output_evaluator.py | 64 ++-- 21 files changed, 431 insertions(+), 625 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d2173886..a9b8836c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,7 @@ All evaluators subclass `strands_evals.evaluators.Evaluator[InputT, OutputT]`: - `Case[InputT, OutputT]`: one test scenario (`input`, `expected_output`, optional `trajectory`, `metadata`) - `Experiment[InputT, OutputT]`: collection of Cases plus evaluators -- Entry point: `experiment.run_evaluations(task_function)` returns a list of reports +- Entry point: `experiment.run_evaluations(task_function)` returns a single `EvaluationReport`. With multiple evaluators, results are flattened across (case, evaluator) pairs and each row is tagged via `cases[i]["evaluator"]`. ### Session / Trace Types diff --git a/README.md b/README.md index 4b0bad05..8114c99b 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ def get_response(case: Case) -> str: return str(agent(case.input)) # Run evaluations -reports = experiment.run_evaluations(get_response) -reports[0].run_display() +report = experiment.run_evaluations(get_response) +report.run_display() ``` ## Installation @@ -194,8 +194,8 @@ evaluators = [HelpfulnessEvaluator()] experiment = Experiment[str, str](cases=test_cases, evaluators=evaluators) # Run evaluations -reports = experiment.run_evaluations(user_task_function) -reports[0].run_display() +report = experiment.run_evaluations(user_task_function) +report.run_display() ``` ### Multi-turn Conversation Simulation @@ -255,7 +255,7 @@ evaluators = [ ] experiment = Experiment(cases=test_cases, evaluators=evaluators) -reports = experiment.run_evaluations(task_function) +report = experiment.run_evaluations(task_function) ``` **Key Benefits:** @@ -310,7 +310,7 @@ def task_function(case: Case) -> dict: cases = [Case(name="heat_control", input="Turn on the heat to 72 degrees")] experiment = Experiment(cases=cases, evaluators=[GoalSuccessRateEvaluator()]) -reports = experiment.run_evaluations(task_function) +report = experiment.run_evaluations(task_function) ``` **Key Benefits:** @@ -369,10 +369,10 @@ experiment = Experiment( ), ) -reports = experiment.run_evaluations(task_function) +report = experiment.run_evaluations(task_function) # Display results with recommendations -reports[0].display(include_recommendations=True) +report.display(include_recommendations=True) ``` You can also use the detectors standalone on any `Session` object (`strands_evals.types.trace.Session`): @@ -498,8 +498,8 @@ def get_response(case: Case) -> str: {"text": case.input.instruction} ])) -reports = experiment.run_evaluations(get_response) -reports[0].run_display() +report = experiment.run_evaluations(get_response) +report.run_display() ``` ## Available Evaluators @@ -586,14 +586,13 @@ metrics = { "user_satisfaction": "Subjective helpfulness ratings" } -# Generate analysis reports -reports = experiment.run_evaluations(task_function) -reports[0].run_display() # Interactive display with metrics breakdown +# Generate analysis report +report = experiment.run_evaluations(task_function) +report.run_display() # Interactive display with metrics breakdown -# Flatten multiple evaluator reports into a single combined view -from strands_evals.types.evaluation_report import EvaluationReport -combined = EvaluationReport.flatten(reports) -combined.display(include_recommendations=True) +# Multi-evaluator runs return a single flattened report; each row is tagged with its evaluator +# via cases[i]["evaluator"], so you can filter or group without an extra flatten step. +report.display(include_recommendations=True) ``` ## Best Practices diff --git a/SKILL.md b/SKILL.md index 2244d351..db24d0a8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -34,19 +34,13 @@ case = Case[str, str]( metadata={"category": "knowledge"}, ) experiment = Experiment[str, str](cases=[case], evaluators=[...]) -reports = experiment.run_evaluations(task_function) -reports[0].run_display() +report = experiment.run_evaluations(task_function) +report.run_display() ``` `task_function(case: Case)` returns either a string output or, for trace-based evaluators, a dict like `{"output": ..., "trajectory": Session}`. -Multiple reports can be flattened: - -```python -from strands_evals.types.evaluation_report import EvaluationReport -combined = EvaluationReport.flatten(reports) -combined.display(include_recommendations=True) -``` +`run_evaluations()` always returns a single `EvaluationReport`. With one evaluator, the report is keyed to that evaluator. With multiple, results are flattened into one report and each row is tagged via `report.cases[i]["evaluator"]`. Persist experiments: @@ -249,7 +243,7 @@ rca = analyze_root_cause(session) Display recommendations on the report: ```python -reports[0].display(include_recommendations=True) +report.display(include_recommendations=True) ``` ## ExperimentGenerator (auto test-case generation) diff --git a/src/strands_evals/chaos/experiment.py b/src/strands_evals/chaos/experiment.py index e7bc977a..a553c544 100644 --- a/src/strands_evals/chaos/experiment.py +++ b/src/strands_evals/chaos/experiment.py @@ -56,7 +56,7 @@ def my_task(case): evaluators=[my_evaluator], ) - reports = experiment.run_evaluations(task=my_task) + report = experiment.run_evaluations(task=my_task) """ def __init__( @@ -125,7 +125,7 @@ def run_evaluations( self, task: Callable[[ChaosCase], Any], **kwargs, - ) -> list[EvaluationReport]: + ) -> EvaluationReport: """Run evaluations across all ChaosCase objects. Delegates to run_evaluations_async with max_workers=1, mirroring the @@ -138,7 +138,7 @@ def run_evaluations( **kwargs: Additional kwargs passed to the base Experiment.run_evaluations_async. Returns: - List of EvaluationReport objects. + A single flattened EvaluationReport. Raises: ValueError: If an async task is passed (use run_evaluations_async instead). @@ -157,7 +157,7 @@ async def run_evaluations_async( task: Callable[[ChaosCase], Any], max_workers: int = 10, **kwargs, - ) -> list[EvaluationReport]: + ) -> EvaluationReport: """Run evaluations asynchronously across all ChaosCase objects. Wraps the user's task to set the ContextVar before each case execution. @@ -169,15 +169,15 @@ async def run_evaluations_async( **kwargs: Additional kwargs passed to the base Experiment.run_evaluations_async. Returns: - List of EvaluationReport objects. + A single flattened EvaluationReport. """ wrapped = self._wrap_task(task) - reports = await self._experiment.run_evaluations_async(wrapped, max_workers=max_workers, **kwargs) + report = await self._experiment.run_evaluations_async(wrapped, max_workers=max_workers, **kwargs) logger.info( - "cases=<%d>, reports=<%d> | chaos experiment complete", + "cases=<%d>, scores=<%d> | chaos experiment complete", len(self._cases), - len(reports), + len(report.scores), ) - return reports + return report diff --git a/src/strands_evals/experiment.py b/src/strands_evals/experiment.py index 40ec86ae..2c1279c8 100644 --- a/src/strands_evals/experiment.py +++ b/src/strands_evals/experiment.py @@ -544,7 +544,7 @@ def run_evaluations( self, task: Callable[[Case[InputT, OutputT]], OutputT | dict[str, Any]], evaluation_data_store: EvaluationDataStore | None = None, - ) -> list[EvaluationReport]: + ) -> EvaluationReport: """ Run the evaluations for all of the test cases with all evaluators. @@ -557,8 +557,8 @@ def run_evaluations( results are loaded instead of running the task, and new results are saved after task execution. Return: - A list of EvaluationReport objects, one for each evaluator, containing the overall score, - individual case results, and basic feedback for each test case. + A single EvaluationReport containing every (case, evaluator) result. Each case row is + tagged with its evaluator via the `evaluator` field on `cases`. """ if asyncio.iscoroutinefunction(task): raise ValueError("Async task is not supported. Please use run_evaluations_async instead.") @@ -570,7 +570,7 @@ async def run_evaluations_async( task: Callable, max_workers: int = 10, evaluation_data_store: EvaluationDataStore | None = None, - ) -> list[EvaluationReport]: + ) -> EvaluationReport: """ Run evaluations asynchronously using a queue for parallel processing. @@ -583,7 +583,8 @@ async def run_evaluations_async( results are loaded instead of running the task, and new results are saved after task execution. Returns: - List of EvaluationReport objects, one for each evaluator, containing evaluation results + A single EvaluationReport flattened across every evaluator. Each row in `cases` carries + an `evaluator` key naming which evaluator produced it. """ if evaluation_data_store is not None: self._validate_case_names() @@ -625,7 +626,7 @@ async def run_evaluations_async( recommendation = result.get("recommendation") for eval_result in result["evaluator_results"]: eval_name = eval_result["evaluator_name"] - evaluator_data[eval_name]["cases"].append(case_data) + evaluator_data[eval_name]["cases"].append({**case_data, "evaluator": eval_name}) evaluator_data[eval_name]["scores"].append(eval_result["score"]) evaluator_data[eval_name]["test_passes"].append(eval_result["test_pass"]) evaluator_data[eval_name]["reasons"].append(eval_result["reason"]) @@ -639,7 +640,6 @@ async def run_evaluations_async( data = evaluator_data[eval_name] scores = data["scores"] report = EvaluationReport( - evaluator_name=eval_name, overall_score=sum(scores) / len(scores) if scores else 0, scores=scores, test_passes=data["test_passes"], @@ -651,7 +651,11 @@ async def run_evaluations_async( ) reports.append(report) - return reports + # Each case row already carries its evaluator tag (see worker aggregation above), so + # single-evaluator runs return as-is and multi-evaluator runs simply concatenate. + if len(reports) == 1: + return reports[0] + return EvaluationReport.flatten(reports) def to_dict(self) -> dict: """ diff --git a/src/strands_evals/experimental/redteam/experiment.py b/src/strands_evals/experimental/redteam/experiment.py index 38cb89bd..d56640d2 100644 --- a/src/strands_evals/experimental/redteam/experiment.py +++ b/src/strands_evals/experimental/redteam/experiment.py @@ -75,10 +75,10 @@ async def run_evaluations_async( # type: ignore[override] ) -> RedTeamReport: # max_workers=1: parallel runs would interleave on the shared target Agent. task = task or self._default_task() - reports = await super().run_evaluations_async( + report = await super().run_evaluations_async( task, max_workers=max_workers, evaluation_data_store=evaluation_data_store ) - return RedTeamReport.from_evaluation_reports(reports) + return RedTeamReport.from_evaluation_report(report) def _default_task(self) -> Callable[[Case[InputT, OutputT]], Any]: if self._target is None: diff --git a/src/strands_evals/experimental/redteam/report.py b/src/strands_evals/experimental/redteam/report.py index 8ab1ea7a..08b155b5 100644 --- a/src/strands_evals/experimental/redteam/report.py +++ b/src/strands_evals/experimental/redteam/report.py @@ -50,40 +50,30 @@ class RedTeamReport(EvaluationReport): """Case-centric report for red team evaluation. Note: - ``trajectory`` holds raw tool I/O — sanitize before sharing if + `trajectory` holds raw tool I/O — sanitize before sharing if target tools return sensitive data. """ @classmethod - def from_evaluation_reports(cls, reports: list[EvaluationReport]) -> RedTeamReport: - """Merge per-evaluator reports into a single case-centric report.""" - scores: list[float] = [] - cases: list[dict] = [] - passes: list[bool] = [] - reasons: list[str] = [] - detailed: list = [] - - for report in reports: - evaluator = report.evaluator_name or "evaluator" - n = len(report.cases) - if not (len(report.scores) == n and len(report.test_passes) == n and len(report.reasons) == n): - raise ValueError(f"EvaluationReport {evaluator!r}: cases/scores/passes/reasons length mismatch") - # detailed_results is optional; pad with [] when shorter than cases. - for i, case_data in enumerate(report.cases): - cases.append({**case_data, "evaluator": evaluator}) - scores.append(report.scores[i]) - passes.append(report.test_passes[i]) - reasons.append(report.reasons[i]) - detailed.append(report.detailed_results[i] if i < len(report.detailed_results) else []) + def from_evaluation_report(cls, report: EvaluationReport) -> RedTeamReport: + """Wrap a flattened evaluation report as a case-centric red team report. + + The base `Experiment.run_evaluations_async` already tags each case row with its + `evaluator` (regardless of evaluator count). We reuse that shape directly. + """ + n = len(report.cases) + if not (len(report.scores) == n and len(report.test_passes) == n and len(report.reasons) == n): + raise ValueError("EvaluationReport: cases/scores/passes/reasons length mismatch") + + cases = [{**case_data, "evaluator": case_data.get("evaluator", "evaluator")} for case_data in report.cases] return cls( - evaluator_name="RedTeam", - overall_score=sum(scores) / len(scores) if scores else 0.0, - scores=scores, + overall_score=report.overall_score, + scores=list(report.scores), cases=cases, - test_passes=passes, - reasons=reasons, - detailed_results=detailed, + test_passes=list(report.test_passes), + reasons=list(report.reasons), + detailed_results=[report.detailed_results[i] if i < len(report.detailed_results) else [] for i in range(n)], ) def attack_results(self) -> list[AttackResult]: diff --git a/src/strands_evals/types/evaluation_report.py b/src/strands_evals/types/evaluation_report.py index 00743c8b..b263d3eb 100644 --- a/src/strands_evals/types/evaluation_report.py +++ b/src/strands_evals/types/evaluation_report.py @@ -12,15 +12,14 @@ class EvaluationReport(BaseModel): A report of the evaluation of a task. Attributes: - evaluator_name: The name of the evaluator that produced this report. overall_score: The overall score of the task. scores: A list of the score for each test case in order. - cases: A list of records for each test case. + cases: A list of records for each test case. Each record carries an `evaluator` key naming + the evaluator that produced that row. test_passes: A list of booleans indicating whether the test pass or fail. reasons: A list of reason for each test case. """ - evaluator_name: str = "" overall_score: float scores: list[float] cases: list[dict] @@ -32,16 +31,20 @@ class EvaluationReport(BaseModel): @classmethod def flatten(cls, reports: list["EvaluationReport"]) -> "EvaluationReport": - """Flatten multiple evaluation reports into a single report.""" + """Concatenate multiple evaluation reports into one. + + The base `Experiment` already returns a flattened report; this helper exists for callers + that built reports separately (e.g., across multiple experiments) and want to merge them. + Each row's `evaluator` tag is preserved as-is. + """ if not reports: return cls(overall_score=0.0, scores=[], cases=[], test_passes=[]) scores, cases, passes, reasons, detailed, diags, recs = [], [], [], [], [], [], [] for report in reports: - evaluator = report.evaluator_name or "Unknown" for i, case in enumerate(report.cases): - cases.append({**case, "evaluator": evaluator}) + cases.append(dict(case)) scores.append(report.scores[i] if i < len(report.scores) else 0.0) passes.append(report.test_passes[i] if i < len(report.test_passes) else False) reasons.append(report.reasons[i] if i < len(report.reasons) else "") @@ -50,7 +53,6 @@ def flatten(cls, reports: list["EvaluationReport"]) -> "EvaluationReport": recs.append(report.recommendations[i] if i < len(report.recommendations) else None) return cls( - evaluator_name="Combined", overall_score=sum(scores) / len(scores) if scores else 0.0, scores=scores, cases=cases, diff --git a/tests/strands_evals/chaos/test_experiment.py b/tests/strands_evals/chaos/test_experiment.py index 47e5a1e2..dd4dec59 100644 --- a/tests/strands_evals/chaos/test_experiment.py +++ b/tests/strands_evals/chaos/test_experiment.py @@ -124,10 +124,8 @@ def task(case: ChaosCase): chaos_cases = ChaosCase.expand(cases, effect_maps, include_no_effect_baseline=True) experiment = ChaosExperiment(cases=chaos_cases, evaluators=[evaluator]) - reports = experiment.run_evaluations(task=task) + report = experiment.run_evaluations(task=task) - assert len(reports) >= 1 - report = reports[0] # 2 cases × 3 conditions = 6 scores assert len(report.scores) == 6 @@ -154,8 +152,8 @@ async def async_task(case: ChaosCase): assert active is case return "async_output" - reports = await experiment.run_evaluations_async(task=async_task, max_workers=2) - assert len(reports) >= 1 + report = await experiment.run_evaluations_async(task=async_task, max_workers=2) + assert len(report.scores) >= 1 @pytest.mark.asyncio async def test_run_evaluations_async_context_var_reset(self, cases, effect_maps, evaluator): diff --git a/tests/strands_evals/experimental/redteam/test_report.py b/tests/strands_evals/experimental/redteam/test_report.py index 703d248d..6f4a90cd 100644 --- a/tests/strands_evals/experimental/redteam/test_report.py +++ b/tests/strands_evals/experimental/redteam/test_report.py @@ -16,19 +16,26 @@ def _case(name: str, risk_category: str, strategy: str, severity: str) -> dict: def _eval_report(evaluator: str, cases: list[dict], scores: list[float], passes: list[bool], reasons: list[str]): + # Mirror what Experiment.run_evaluations does: tag each case row with its evaluator. + tagged = [{**c, "evaluator": evaluator} for c in cases] return EvaluationReport( - evaluator_name=evaluator, overall_score=sum(scores) / len(scores) if scores else 0.0, scores=scores, - cases=cases, + cases=tagged, test_passes=passes, reasons=reasons, ) +def _flatten(*reports: EvaluationReport) -> EvaluationReport: + """Match what Experiment.run_evaluations now hands to RedTeamReport.from_evaluation_report.""" + if len(reports) == 1: + return reports[0] + return EvaluationReport.flatten(list(reports)) + + def _empty_report() -> RedTeamReport: return RedTeamReport( - evaluator_name="RedTeam", overall_score=0.0, scores=[], cases=[], @@ -36,9 +43,10 @@ def _empty_report() -> RedTeamReport: ) -class TestFromEvaluationReports: - def test_empty_reports(self): - report = RedTeamReport.from_evaluation_reports([]) +class TestFromEvaluationReport: + def test_empty_report(self): + empty = EvaluationReport(overall_score=0.0, scores=[], cases=[], test_passes=[]) + report = RedTeamReport.from_evaluation_report(empty) assert report.attack_results() == [] assert report.overall_score == 0.0 assert report.failed_cases == [] @@ -51,7 +59,7 @@ def test_single_evaluator_single_case(self): passes=[False], reasons=["bypassed"], ) - report = RedTeamReport.from_evaluation_reports([eval_report]) + report = RedTeamReport.from_evaluation_report(_flatten(eval_report)) results = report.attack_results() assert len(results) == 1 @@ -69,7 +77,7 @@ def test_multiple_evaluators_merge_on_case_name(self): r1 = _eval_report("judge", cases, scores=[0.0], passes=[False], reasons=["bypassed"]) r2 = _eval_report("attack_success", cases, scores=[0.9], passes=[False], reasons=["full compromise"]) - report = RedTeamReport.from_evaluation_reports([r1, r2]) + report = RedTeamReport.from_evaluation_report(_flatten(r1, r2)) results = report.attack_results() assert len(results) == 1 @@ -86,7 +94,7 @@ def test_missing_metadata_fills_defaults(self): passes=[True], reasons=[""], ) - report = RedTeamReport.from_evaluation_reports([eval_report]) + report = RedTeamReport.from_evaluation_report(_flatten(eval_report)) r = report.attack_results()[0] assert r.risk_category == "unknown" @@ -152,12 +160,12 @@ def _build(self) -> RedTeamReport: _case("c1", "guideline_bypass", "gradual_escalation", "high"), _case("c2", "system_prompt_leak", "gradual_escalation", "high"), ] - return RedTeamReport.from_evaluation_reports( - [ + return RedTeamReport.from_evaluation_report( + _flatten( _eval_report( "judge", cases_a, scores=[1.0, 0.0, 0.0], passes=[True, False, False], reasons=["", "", ""] ), - ] + ) ) def test_failed_cases_sorted_by_score(self): @@ -204,7 +212,7 @@ def test_no_results_does_not_raise(self): def test_with_results_does_not_raise(self): cases = [_case("c0", "guideline_bypass", "gradual_escalation", "high")] - report = RedTeamReport.from_evaluation_reports( - [_eval_report("judge", cases, scores=[0.0], passes=[False], reasons=["bypassed"])] + report = RedTeamReport.from_evaluation_report( + _flatten(_eval_report("judge", cases, scores=[0.0], passes=[False], reasons=["bypassed"])) ) report.display() diff --git a/tests/strands_evals/mappers/test_langchain_mapper_integration.py b/tests/strands_evals/mappers/test_langchain_mapper_integration.py index 816aff99..cb1cc458 100644 --- a/tests/strands_evals/mappers/test_langchain_mapper_integration.py +++ b/tests/strands_evals/mappers/test_langchain_mapper_integration.py @@ -316,11 +316,10 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[TrajectoryCheckEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True def test_traceloop_mapper_extracts_tools(self): """Verify that available tools are extracted from inference spans.""" @@ -388,11 +387,10 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[TrajectoryCheckEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True def test_openinference_mapper_extracts_tools(self): """Verify that available tools are extracted from LLM spans.""" @@ -452,13 +450,12 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[TrajectoryCheckEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert len(reports[0].scores) == 2 - assert all(score == 1.0 for score in reports[0].scores) - assert all(reports[0].test_passes) - assert reports[0].overall_score == 1.0 + assert len(report.scores) == 2 + assert all(score == 1.0 for score in report.scores) + assert all(report.test_passes) + assert report.overall_score == 1.0 def test_multiple_openinference_cases(self): """Evaluate multiple cases using OpenInference mapper.""" @@ -497,12 +494,11 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[TrajectoryCheckEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert len(reports[0].scores) == 2 - assert all(score == 1.0 for score in reports[0].scores) - assert all(reports[0].test_passes) + assert len(report.scores) == 2 + assert all(score == 1.0 for score in report.scores) + assert all(report.test_passes) @pytest.mark.asyncio @@ -531,8 +527,7 @@ async def async_task(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[TrajectoryCheckEvaluator()]) - reports = await experiment.run_evaluations_async(async_task) + report = await experiment.run_evaluations_async(async_task) - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True diff --git a/tests/strands_evals/test_eval_task.py b/tests/strands_evals/test_eval_task.py index 64871879..9ea72dbf 100644 --- a/tests/strands_evals/test_eval_task.py +++ b/tests/strands_evals/test_eval_task.py @@ -107,8 +107,8 @@ def my_task(): cases=[Case(name="test", input="hi")], evaluators=[PassingEvaluator()], ) - reports = experiment.run_evaluations(my_task) - assert reports[0].scores[0] == 1.0 + report = experiment.run_evaluations(my_task) + assert report.scores[0] == 1.0 class TestEvalTaskHandler: diff --git a/tests/strands_evals/test_experiment.py b/tests/strands_evals/test_experiment.py index 095b850a..74511bb8 100644 --- a/tests/strands_evals/test_experiment.py +++ b/tests/strands_evals/test_experiment.py @@ -223,11 +223,10 @@ def test_experiment_run_evaluations(mock_evaluator): def echo_task(c): return c.input - reports = experiment.run_evaluations(echo_task) + report = experiment.run_evaluations(echo_task) - # Returns list of reports, one per evaluator - assert len(reports) == 1 - report = reports[0] + # Single-evaluator runs tag every case row with the evaluator name. + assert {row["evaluator"] for row in report.cases} == {"MockEvaluator"} assert len(report.scores) == 2 assert report.scores[0] == 1.0 # match assert report.scores[1] == 0.0 # no match @@ -236,12 +235,17 @@ def echo_task(c): assert report.overall_score == 0.5 assert len(report.cases) == 2 - # Test with multiple evaluators - each gets its own report + # Multi-evaluator runs flatten across evaluators; each row is tagged with its evaluator. experiment2 = Experiment(cases=cases, evaluators=[mock_evaluator, MockEvaluator2()]) - reports2 = experiment2.run_evaluations(echo_task) - assert len(reports2) == 2 - assert reports2[0].scores[0] == 1.0 # MockEvaluator on match - assert reports2[1].scores[0] == 0.5 # MockEvaluator2 always returns 0.5 + report2 = experiment2.run_evaluations(echo_task) + assert {row["evaluator"] for row in report2.cases} == {"MockEvaluator", "MockEvaluator2"} + assert len(report2.scores) == 4 + + by_evaluator: dict[str, list[float]] = {"MockEvaluator": [], "MockEvaluator2": []} + for row, score in zip(report2.cases, report2.scores, strict=True): + by_evaluator[row["evaluator"]].append(score) + assert by_evaluator["MockEvaluator"] == [1.0, 0.0] # match, no_match + assert by_evaluator["MockEvaluator2"] == [0.5, 0.5] # always 0.5 def test_experiment_run_evaluations_task_executed_once(): @@ -748,21 +752,20 @@ def task(c): case1 = Case(name="test1", input="world", expected_output="world") experiment = Experiment(cases=[case, case1], evaluators=[MockEvaluator()]) - reports = await experiment.run_evaluations_async(task) + report = await experiment.run_evaluations_async(task) - assert len(reports) == 1 - report = reports[0] + assert {row["evaluator"] for row in report.cases} == {"MockEvaluator"} assert len(report.scores) == 2 assert all(score == 1.0 for score in report.scores) assert all(test_pass for test_pass in report.test_passes) assert report.overall_score == 1.0 - # Test with multiple evaluators + # Multi-evaluator runs flatten across evaluators. experiment2 = Experiment(cases=[case], evaluators=[MockEvaluator(), MockEvaluator2()]) - reports2 = await experiment2.run_evaluations_async(task) - assert len(reports2) == 2 - assert reports2[0].scores[0] == 1.0 # MockEvaluator - assert reports2[1].scores[0] == 0.5 # MockEvaluator2 + report2 = await experiment2.run_evaluations_async(task) + by_evaluator = {row["evaluator"]: report2.scores[i] for i, row in enumerate(report2.cases)} + assert by_evaluator["MockEvaluator"] == 1.0 + assert by_evaluator["MockEvaluator2"] == 0.5 @pytest.mark.asyncio @@ -776,10 +779,8 @@ async def async_task(c): case = Case(name="test", input="hello", expected_output="hello") case1 = Case(name="test1", input="world", expected_output="world") experiment = Experiment(cases=[case, case1], evaluators=[MockEvaluator()]) - reports = await experiment.run_evaluations_async(async_task) + report = await experiment.run_evaluations_async(async_task) - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 2 assert all(score == 1.0 for score in report.scores) assert all(test_pass for test_pass in report.test_passes) @@ -798,10 +799,8 @@ async def variable_delay_task(c): await asyncio.sleep(random.uniform(0.01, 0.1)) return c.input - reports = await experiment.run_evaluations_async(variable_delay_task, max_workers=5) + report = await experiment.run_evaluations_async(variable_delay_task, max_workers=5) - assert len(reports) == 1 - report = reports[0] for i, case_data in enumerate(report.cases): assert case_data["name"] == f"case_{i}", ( f"report.cases[{i}] has name '{case_data['name']}', expected 'case_{i}'" @@ -821,10 +820,8 @@ def failing_task(c): case = Case(name="test", input="hello", expected_output="hello") case1 = Case(name="test1", input="world", expected_output="world") experiment = Experiment(cases=[case, case1], evaluators=[MockEvaluator()]) - reports = await experiment.run_evaluations_async(failing_task) + report = await experiment.run_evaluations_async(failing_task) - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 2 # One of the cases should have failed (score 0) and one passed (score 1) assert 0.0 in report.scores @@ -843,10 +840,8 @@ def test_experiment_run_evaluations_with_interactions(): def task_with_interactions(c): return {"output": c.input, "interactions": interactions} - reports = experiment.run_evaluations(task_with_interactions) + report = experiment.run_evaluations(task_with_interactions) - assert len(reports) == 1 - report = reports[0] assert len(report.cases) == 1 assert report.cases[0]["actual_interactions"] == interactions assert report.cases[0]["expected_interactions"] == interactions @@ -919,11 +914,9 @@ def test_experiment_run_evaluations_records_exception_in_span(mock_span): def failing_task(c): raise ValueError("Test error") - reports = experiment.run_evaluations(failing_task) + report = experiment.run_evaluations(failing_task) # Verify error was handled and report contains error info - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 1 assert report.scores[0] == 0 assert report.test_passes[0] is False @@ -937,11 +930,10 @@ def test_experiment_run_evaluations_with_unnamed_case(mock_span, simple_task): with patch.object(experiment._tracer, "start_as_current_span", return_value=mock_span): with patch("strands_evals.experiment.format_trace_id", return_value="mock_trace_id"): - reports = experiment.run_evaluations(simple_task) + report = experiment.run_evaluations(simple_task) # Should complete successfully - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 + assert report.scores[0] == 1.0 @pytest.mark.asyncio @@ -1038,11 +1030,9 @@ async def test_experiment_run_evaluations_async_records_exception(mock_span): async def failing_async_task(c): raise ValueError("Async test error") - reports = await experiment.run_evaluations_async(failing_async_task) + report = await experiment.run_evaluations_async(failing_async_task) # Verify the error was handled gracefully - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 1 assert report.scores[0] == 0 assert "Async test error" in report.reasons[0] @@ -1087,11 +1077,10 @@ def test_experiment_run_evaluations_multiple_cases(mock_span, simple_task): with patch.object(experiment._tracer, "start_as_current_span", return_value=mock_span): with patch("strands_evals.experiment.format_trace_id", return_value="mock_trace_id"): - reports = experiment.run_evaluations(simple_task) + report = experiment.run_evaluations(simple_task) - assert len(reports) == 1 - assert len(reports[0].scores) == 2 - assert all(score == 1.0 for score in reports[0].scores) + assert len(report.scores) == 2 + assert all(score == 1.0 for score in report.scores) def test_experiment_run_evaluations_evaluator_error_isolated(): @@ -1104,20 +1093,25 @@ def test_experiment_run_evaluations_evaluator_error_isolated(): def echo_task(c): return c.input - reports = experiment.run_evaluations(echo_task) + report = experiment.run_evaluations(echo_task) + + # Multi-evaluator runs return one flattened report; rows are tagged by evaluator. + assert len(report.scores) == 2 - assert len(reports) == 2 + rows_by_evaluator = {row["evaluator"]: i for i, row in enumerate(report.cases)} + mock_idx = rows_by_evaluator["MockEvaluator"] + throwing_idx = rows_by_evaluator["ThrowingEvaluator"] - # First evaluator (MockEvaluator) should succeed - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True - assert reports[0].reasons[0] == "Mock evaluation" + # MockEvaluator should succeed + assert report.scores[mock_idx] == 1.0 + assert report.test_passes[mock_idx] is True + assert report.reasons[mock_idx] == "Mock evaluation" - # Second evaluator (ThrowingEvaluator) should fail with error message - assert reports[1].scores[0] == 0 - assert reports[1].test_passes[0] is False - assert "Evaluator error" in reports[1].reasons[0] - assert "Evaluator exploded" in reports[1].reasons[0] + # ThrowingEvaluator should fail with error message + assert report.scores[throwing_idx] == 0 + assert report.test_passes[throwing_idx] is False + assert "Evaluator error" in report.reasons[throwing_idx] + assert "Evaluator exploded" in report.reasons[throwing_idx] def testis_throttling_error_detects_model_throttled_exception(): @@ -1193,13 +1187,12 @@ def throttling_task(c): with patch("strands_evals.experiment._INITIAL_RETRY_DELAY", 0.01): with patch("strands_evals.experiment._MAX_RETRY_DELAY", 0.02): - reports = experiment.run_evaluations(throttling_task) + report = experiment.run_evaluations(throttling_task) # Task should have been retried assert call_count == 3 - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True def test_experiment_run_evaluations_fails_after_max_retries(): @@ -1217,14 +1210,13 @@ def always_throttling_task(c): with patch("strands_evals.experiment._MAX_RETRY_ATTEMPTS", 3): with patch("strands_evals.experiment._INITIAL_RETRY_DELAY", 0.01): with patch("strands_evals.experiment._MAX_RETRY_DELAY", 0.02): - reports = experiment.run_evaluations(always_throttling_task) + report = experiment.run_evaluations(always_throttling_task) # Should have retried max times assert call_count == 3 - assert len(reports) == 1 - assert reports[0].scores[0] == 0 - assert reports[0].test_passes[0] is False - assert "An error occurred" in reports[0].reasons[0] + assert report.scores[0] == 0 + assert report.test_passes[0] is False + assert "An error occurred" in report.reasons[0] def test_experiment_run_evaluations_no_retry_on_non_throttling(): @@ -1239,13 +1231,12 @@ def non_throttling_error_task(c): case = Case(name="test", input="hello", expected_output="hello") experiment = Experiment(cases=[case], evaluators=[MockEvaluator()]) - reports = experiment.run_evaluations(non_throttling_error_task) + report = experiment.run_evaluations(non_throttling_error_task) # Should NOT have retried assert call_count == 1 - assert len(reports) == 1 - assert reports[0].scores[0] == 0 - assert "Invalid input" in reports[0].reasons[0] + assert report.scores[0] == 0 + assert "Invalid input" in report.reasons[0] def test_experiment_run_evaluations_exponential_backoff(): @@ -1302,13 +1293,12 @@ def simple_task(c): with patch("strands_evals.experiment._INITIAL_RETRY_DELAY", 0.01): with patch("strands_evals.experiment._MAX_RETRY_DELAY", 0.02): - reports = experiment.run_evaluations(simple_task) + report = experiment.run_evaluations(simple_task) # Evaluator should have been retried assert evaluator.call_count == 3 - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True @pytest.mark.asyncio @@ -1328,13 +1318,12 @@ async def throttling_task(c): with patch("strands_evals.experiment._INITIAL_RETRY_DELAY", 0.01): with patch("strands_evals.experiment._MAX_RETRY_DELAY", 0.02): - reports = await experiment.run_evaluations_async(throttling_task, max_workers=1) + report = await experiment.run_evaluations_async(throttling_task, max_workers=1) # Task should have been retried assert call_count == 3 - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True @pytest.mark.asyncio @@ -1353,14 +1342,13 @@ async def always_throttling_task(c): with patch("strands_evals.experiment._MAX_RETRY_ATTEMPTS", 3): with patch("strands_evals.experiment._INITIAL_RETRY_DELAY", 0.01): with patch("strands_evals.experiment._MAX_RETRY_DELAY", 0.02): - reports = await experiment.run_evaluations_async(always_throttling_task, max_workers=1) + report = await experiment.run_evaluations_async(always_throttling_task, max_workers=1) # Should have retried max times assert call_count == 3 - assert len(reports) == 1 - assert reports[0].scores[0] == 0 - assert reports[0].test_passes[0] is False - assert "An error occurred" in reports[0].reasons[0] + assert report.scores[0] == 0 + assert report.test_passes[0] is False + assert "An error occurred" in report.reasons[0] @pytest.mark.asyncio @@ -1376,13 +1364,12 @@ async def non_throttling_error_task(c): case = Case(name="test", input="hello", expected_output="hello") experiment = Experiment(cases=[case], evaluators=[MockEvaluator()]) - reports = await experiment.run_evaluations_async(non_throttling_error_task, max_workers=1) + report = await experiment.run_evaluations_async(non_throttling_error_task, max_workers=1) # Should NOT have retried assert call_count == 1 - assert len(reports) == 1 - assert reports[0].scores[0] == 0 - assert "Invalid input" in reports[0].reasons[0] + assert report.scores[0] == 0 + assert "Invalid input" in report.reasons[0] @pytest.mark.asyncio @@ -1442,13 +1429,12 @@ async def simple_task(c): with patch("strands_evals.experiment._INITIAL_RETRY_DELAY", 0.01): with patch("strands_evals.experiment._MAX_RETRY_DELAY", 0.02): - reports = await experiment.run_evaluations_async(simple_task, max_workers=1) + report = await experiment.run_evaluations_async(simple_task, max_workers=1) # Evaluator should have been retried assert evaluator.call_count == 3 - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True @pytest.mark.asyncio @@ -1506,20 +1492,17 @@ def test_deterministic_evaluator_alongside_mock_evaluator(): cases=cases, evaluators=[Equals(), MockEvaluator()], ) - reports = experiment.run_evaluations(_simulate_agent) - - assert len(reports) == 2 + report = experiment.run_evaluations(_simulate_agent) - # Equals: both cases match expected_output exactly - equals_report = reports[0] - assert equals_report.scores == [1.0, 1.0] - assert equals_report.test_passes == [True, True] - assert equals_report.overall_score == 1.0 + # Multi-evaluator runs flatten across (case, evaluator) pairs. + assert len(report.scores) == 4 + assert report.overall_score == 1.0 - # MockEvaluator: also matches (actual==expected) - mock_report = reports[1] - assert mock_report.scores == [1.0, 1.0] - assert mock_report.test_passes == [True, True] + by_evaluator: dict[str, list[tuple[float, bool]]] = {"Equals": [], "MockEvaluator": []} + for row, score, test_pass in zip(report.cases, report.scores, report.test_passes, strict=True): + by_evaluator[row["evaluator"]].append((score, test_pass)) + assert by_evaluator["Equals"] == [(1.0, True), (1.0, True)] + assert by_evaluator["MockEvaluator"] == [(1.0, True), (1.0, True)] @pytest.mark.asyncio @@ -1537,13 +1520,11 @@ async def test_deterministic_evaluator_alongside_mock_evaluator_async(): cases=cases, evaluators=[Equals(), MockEvaluator()], ) - reports = await experiment.run_evaluations_async(_simulate_agent) + report = await experiment.run_evaluations_async(_simulate_agent) - assert len(reports) == 2 - assert reports[0].scores == [1.0] - assert reports[0].test_passes == [True] - assert reports[1].scores == [1.0] - assert reports[1].test_passes == [True] + assert len(report.scores) == 2 + assert report.scores == [1.0, 1.0] + assert report.test_passes == [True, True] def test_multiple_deterministic_evaluators_in_experiment(): @@ -1562,12 +1543,12 @@ def test_multiple_deterministic_evaluators_in_experiment(): StartsWith(value="The capital"), ], ) - reports = experiment.run_evaluations(_simulate_agent) + report = experiment.run_evaluations(_simulate_agent) - assert len(reports) == 3 - for report in reports: - assert report.scores == [1.0] - assert report.test_passes == [True] + assert len(report.scores) == 3 + assert report.scores == [1.0, 1.0, 1.0] + assert report.test_passes == [True, True, True] + assert {row["evaluator"] for row in report.cases} == {"Equals", "Contains", "StartsWith"} def test_tool_called_evaluator_with_trajectory_task(): @@ -1588,17 +1569,17 @@ def test_tool_called_evaluator_with_trajectory_task(): Contains(value="4"), ], ) - reports = experiment.run_evaluations(_simulate_agent) + report = experiment.run_evaluations(_simulate_agent) - assert len(reports) == 2 + assert len(report.scores) == 2 + rows_by_evaluator = {row["evaluator"]: i for i, row in enumerate(report.cases)} # calculator was called in trajectory - assert reports[0].scores == [1.0] - assert reports[0].test_passes == [True] - + assert report.scores[rows_by_evaluator["ToolCalled"]] == 1.0 + assert report.test_passes[rows_by_evaluator["ToolCalled"]] is True # output contains "4" - assert reports[1].scores == [1.0] - assert reports[1].test_passes == [True] + assert report.scores[rows_by_evaluator["Contains"]] == 1.0 + assert report.test_passes[rows_by_evaluator["Contains"]] is True def test_tool_called_evaluator_tool_not_found(): @@ -1615,12 +1596,11 @@ def test_tool_called_evaluator_tool_not_found(): cases=cases, evaluators=[ToolCalled(tool_name="calculator")], ) - reports = experiment.run_evaluations(_simulate_agent) + report = experiment.run_evaluations(_simulate_agent) - assert len(reports) == 1 # Agent used knowledge_base and formatter, not calculator - assert reports[0].scores == [0.0] - assert reports[0].test_passes == [False] + assert report.scores == [0.0] + assert report.test_passes == [False] def test_deterministic_evaluator_from_dict_round_trip(): @@ -1656,12 +1636,11 @@ def test_deterministic_evaluator_from_dict_round_trip(): assert restored.evaluators[3].tool_name == "knowledge_base" # Run restored experiment against the same agent — results should be identical - original_reports = experiment.run_evaluations(_simulate_agent) - restored_reports = restored.run_evaluations(_simulate_agent) + original_report = experiment.run_evaluations(_simulate_agent) + restored_report = restored.run_evaluations(_simulate_agent) - for orig, rest in zip(original_reports, restored_reports, strict=True): - assert orig.scores == rest.scores - assert orig.test_passes == rest.test_passes + assert original_report.scores == restored_report.scores + assert original_report.test_passes == restored_report.test_passes def test_deterministic_evaluator_error_isolation(): @@ -1679,18 +1658,20 @@ def test_deterministic_evaluator_error_isolation(): Equals(), ], ) - reports = experiment.run_evaluations(_simulate_agent) + report = experiment.run_evaluations(_simulate_agent) - assert len(reports) == 2 + rows_by_evaluator = {row["evaluator"]: i for i, row in enumerate(report.cases)} + throw = rows_by_evaluator["ThrowingEvaluator"] + eq = rows_by_evaluator["Equals"] # ThrowingEvaluator failed with error isolation - assert reports[0].scores == [0] - assert reports[0].test_passes == [False] - assert "Evaluator exploded" in reports[0].reasons[0] + assert report.scores[throw] == 0 + assert report.test_passes[throw] is False + assert "Evaluator exploded" in report.reasons[throw] # Equals still ran successfully despite the ThrowingEvaluator failure - assert reports[1].scores == [1.0] - assert reports[1].test_passes == [True] + assert report.scores[eq] == 1.0 + assert report.test_passes[eq] is True class DictEvaluationDataStore: @@ -1751,13 +1732,12 @@ def counting_task(c): cases = [Case(name="case1", input="hello", expected_output="hello")] experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) - reports = experiment.run_evaluations(counting_task, evaluation_data_store=store) + report = experiment.run_evaluations(counting_task, evaluation_data_store=store) # Task should NOT have been called assert task_call_count == 0 # Evaluators should still run on cached data - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 + assert report.scores[0] == 1.0 def test_run_evaluations_with_store_requires_case_names(self): """Should raise ValueError if any case lacks a name when store is provided.""" @@ -1785,10 +1765,9 @@ def test_run_evaluations_without_store_unchanged(self): cases = [Case(name="case1", input="hello", expected_output="hello")] experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) - reports = experiment.run_evaluations(lambda c: c.input) + report = experiment.run_evaluations(lambda c: c.input) - assert len(reports) == 1 - assert reports[0].scores[0] == 1.0 + assert report.scores[0] == 1.0 class MockTraceProvider(TraceProvider): @@ -1820,12 +1799,11 @@ def test_run_evaluations_with_provider(self): ) experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) - reports = experiment.run_evaluations(provider.as_task()) + report = experiment.run_evaluations(provider.as_task()) assert provider.call_count == 2 assert set(provider.called_session_ids) == {"sess-1", "sess-2"} - assert len(reports) == 1 - assert reports[0].scores == [1.0, 1.0] + assert report.scores == [1.0, 1.0] @pytest.mark.asyncio async def test_run_evaluations_async_with_provider(self): @@ -1840,12 +1818,11 @@ async def test_run_evaluations_async_with_provider(self): ) experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) - reports = await experiment.run_evaluations_async(provider.as_task()) + report = await experiment.run_evaluations_async(provider.as_task()) assert provider.call_count == 1 assert provider.called_session_ids == ["sess-1"] - assert len(reports) == 1 - assert reports[0].scores == [1.0] + assert report.scores == [1.0] def test_run_evaluations_with_provider_and_data_store_caches(self): """When data store has cached data, provider should not be called for that case.""" @@ -1866,12 +1843,11 @@ def test_run_evaluations_with_provider_and_data_store_caches(self): cases = [Case(name="c1", session_id="sess-1", input="hello", expected_output="hello")] experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) - reports = experiment.run_evaluations(provider.as_task(), evaluation_data_store=store) + report = experiment.run_evaluations(provider.as_task(), evaluation_data_store=store) # Provider should NOT have been called - data was cached assert provider.call_count == 0 - assert len(reports) == 1 - assert reports[0].scores == [1.0] + assert report.scores == [1.0] def test_run_evaluations_with_task_positional_arg_unchanged(self): """Existing positional task argument should continue to work.""" @@ -1879,10 +1855,9 @@ def test_run_evaluations_with_task_positional_arg_unchanged(self): experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) # Positional arg - existing behavior - reports = experiment.run_evaluations(lambda c: c.input) + report = experiment.run_evaluations(lambda c: c.input) - assert len(reports) == 1 - assert reports[0].scores == [1.0] + assert report.scores == [1.0] class TestDiagnoseOnFailure: @@ -1917,11 +1892,10 @@ def test_diagnosis_disabled_when_no_diagnosis_config(self): cases = [Case(name="fail", input="foo", expected_output="bar")] experiment = Experiment(cases=cases, evaluators=[MockEvaluator()]) - reports = experiment.run_evaluations(lambda c: c.input) + report = experiment.run_evaluations(lambda c: c.input) - assert len(reports) == 1 - assert reports[0].diagnoses == [None] - assert reports[0].recommendations == [None] + assert report.diagnoses == [None] + assert report.recommendations == [None] @patch("strands_evals.experiment.Experiment._run_diagnosis") def test_diagnosis_config_on_failure_calls_diagnosis_for_failing_case(self, mock_run_diag): @@ -1946,12 +1920,11 @@ def test_diagnosis_config_on_failure_calls_diagnosis_for_failing_case(self, mock def task_returning_session(c): return {"output": c.input, "trajectory": session} - reports = experiment.run_evaluations(task_returning_session) + report = experiment.run_evaluations(task_returning_session) - assert len(reports) == 1 - assert reports[0].diagnoses[0] is not None - assert reports[0].diagnoses[0]["session_id"] == "sess_1" - assert reports[0].recommendations[0] == "Add disambiguation instructions" + assert report.diagnoses[0] is not None + assert report.diagnoses[0]["session_id"] == "sess_1" + assert report.recommendations[0] == "Add disambiguation instructions" mock_run_diag.assert_called_once() @patch("strands_evals.experiment.Experiment._run_diagnosis") @@ -1964,11 +1937,10 @@ def test_diagnosis_config_on_failure_skips_passing_case(self, mock_run_diag): diagnosis_config=DiagnosisConfig(), ) - reports = experiment.run_evaluations(lambda c: c.input) + report = experiment.run_evaluations(lambda c: c.input) - assert len(reports) == 1 - assert reports[0].diagnoses == [None] - assert reports[0].recommendations == [None] + assert report.diagnoses == [None] + assert report.recommendations == [None] mock_run_diag.assert_not_called() def test_diagnosis_config_returns_none_for_non_session_trajectory(self): @@ -1983,11 +1955,10 @@ def test_diagnosis_config_returns_none_for_non_session_trajectory(self): def task_with_list_trajectory(c): return {"output": c.input, "trajectory": ["step1", "step2"]} - reports = experiment.run_evaluations(task_with_list_trajectory) + report = experiment.run_evaluations(task_with_list_trajectory) - assert len(reports) == 1 - assert reports[0].diagnoses == [None] - assert reports[0].recommendations == [None] + assert report.diagnoses == [None] + assert report.recommendations == [None] @patch("strands_evals.experiment.Experiment._run_diagnosis") def test_diagnosis_config_with_multiple_evaluators(self, mock_run_diag): @@ -2012,12 +1983,15 @@ def evaluate(self, evaluation_case): def task_with_session(c): return {"output": c.input, "trajectory": session} - reports = experiment.run_evaluations(task_with_session) + report = experiment.run_evaluations(task_with_session) - assert len(reports) == 2 - # Both reports share the same diagnosis and recommendation - assert reports[0].diagnoses[0] == reports[1].diagnoses[0] - assert reports[0].recommendations[0] == reports[1].recommendations[0] == "Fix the prompt" + # Multi-evaluator runs flatten across evaluators; one row per evaluator, same diagnosis. + assert len(report.scores) == 2 + rows_by_evaluator = {row["evaluator"]: i for i, row in enumerate(report.cases)} + af = rows_by_evaluator["AlwaysFailEvaluator"] + m2 = rows_by_evaluator["MockEvaluator2"] + assert report.diagnoses[af] == report.diagnoses[m2] + assert report.recommendations[af] == report.recommendations[m2] == "Fix the prompt" mock_run_diag.assert_called_once() @patch("strands_evals.experiment.diagnose_session") @@ -2036,8 +2010,7 @@ def test_diagnosis_exception_returns_none(self, mock_diagnose): def task_with_session(c): return {"output": c.input, "trajectory": session} - reports = experiment.run_evaluations(task_with_session) + report = experiment.run_evaluations(task_with_session) - assert len(reports) == 1 - assert reports[0].diagnoses == [None] - assert reports[0].recommendations == [None] + assert report.diagnoses == [None] + assert report.recommendations == [None] diff --git a/tests/strands_evals/types/test_evaluation_report.py b/tests/strands_evals/types/test_evaluation_report.py index 97f4ba98..47f5121c 100644 --- a/tests/strands_evals/types/test_evaluation_report.py +++ b/tests/strands_evals/types/test_evaluation_report.py @@ -7,87 +7,23 @@ from strands_evals.types.evaluation_report import EvaluationReport -class TestEvaluationReportEvaluatorName: - """Tests for the evaluator_name field on EvaluationReport.""" - - def test_evaluator_name_default_empty(self): - """Test that evaluator_name defaults to empty string.""" - report = EvaluationReport( - overall_score=0.5, - scores=[0.5], - cases=[{"name": "test"}], - test_passes=[True], - ) - assert report.evaluator_name == "" - - def test_evaluator_name_set(self): - """Test that evaluator_name can be set.""" - report = EvaluationReport( - evaluator_name="ResponseRelevance", - overall_score=0.5, - scores=[0.5], - cases=[{"name": "test"}], - test_passes=[True], - ) - assert report.evaluator_name == "ResponseRelevance" - - def test_evaluator_name_serialization(self): - """Test that evaluator_name is included in serialization.""" - report = EvaluationReport( - evaluator_name="TestEvaluator", - overall_score=0.5, - scores=[0.5], - cases=[{"name": "test"}], - test_passes=[True], - ) - data = report.to_dict() - assert data["evaluator_name"] == "TestEvaluator" - - def test_evaluator_name_deserialization(self): - """Test that evaluator_name is restored from dict.""" - data = { - "evaluator_name": "TestEvaluator", - "overall_score": 0.5, - "scores": [0.5], - "cases": [{"name": "test"}], - "test_passes": [True], - } - report = EvaluationReport.from_dict(data) - assert report.evaluator_name == "TestEvaluator" - - def test_evaluator_name_backward_compatible(self): - """Test that reports without evaluator_name can still be loaded.""" - data = { - "overall_score": 0.5, - "scores": [0.5], - "cases": [{"name": "test"}], - "test_passes": [True], - } - report = EvaluationReport.from_dict(data) - assert report.evaluator_name == "" - - class TestEvaluationReportFlatten: """Tests for the flatten() classmethod.""" def test_flatten_empty_list(self): - """Test flattening an empty list returns empty report.""" flattened = EvaluationReport.flatten([]) - assert flattened.evaluator_name == "" assert flattened.overall_score == 0.0 assert flattened.scores == [] assert flattened.cases == [] assert flattened.test_passes == [] def test_flatten_single_report(self): - """Test flattening a single report.""" report = EvaluationReport( - evaluator_name="Evaluator1", overall_score=0.8, scores=[0.9, 0.7], cases=[ - {"name": "case-1", "input": "input1"}, - {"name": "case-2", "input": "input2"}, + {"name": "case-1", "input": "input1", "evaluator": "Evaluator1"}, + {"name": "case-2", "input": "input2", "evaluator": "Evaluator1"}, ], test_passes=[True, True], reasons=["reason1", "reason2"], @@ -95,33 +31,29 @@ def test_flatten_single_report(self): flattened = EvaluationReport.flatten([report]) - assert flattened.evaluator_name == "Combined" assert flattened.overall_score == 0.8 assert len(flattened.cases) == 2 assert flattened.cases[0]["evaluator"] == "Evaluator1" assert flattened.cases[1]["evaluator"] == "Evaluator1" def test_flatten_multiple_reports(self): - """Test flattening multiple reports.""" report1 = EvaluationReport( - evaluator_name="ResponseRelevance", overall_score=0.85, scores=[0.9, 0.8], cases=[ - {"name": "case-1", "input": "input1"}, - {"name": "case-2", "input": "input2"}, + {"name": "case-1", "input": "input1", "evaluator": "ResponseRelevance"}, + {"name": "case-2", "input": "input2", "evaluator": "ResponseRelevance"}, ], test_passes=[True, True], reasons=["relevant", "relevant"], ) report2 = EvaluationReport( - evaluator_name="Equals", overall_score=0.0, scores=[0.0, 0.0], cases=[ - {"name": "case-1", "input": "input1"}, - {"name": "case-2", "input": "input2"}, + {"name": "case-1", "input": "input1", "evaluator": "Equals"}, + {"name": "case-2", "input": "input2", "evaluator": "Equals"}, ], test_passes=[False, False], reasons=["not equal", "not equal"], @@ -129,20 +61,30 @@ def test_flatten_multiple_reports(self): flattened = EvaluationReport.flatten([report1, report2]) - assert flattened.evaluator_name == "Combined" assert flattened.overall_score == pytest.approx(0.425) assert len(flattened.cases) == 4 assert len(flattened.scores) == 4 assert len(flattened.test_passes) == 4 assert len(flattened.reasons) == 4 + assert [c["evaluator"] for c in flattened.cases] == [ + "ResponseRelevance", + "ResponseRelevance", + "Equals", + "Equals", + ] def test_flatten_preserves_case_data(self): - """Test that flattening preserves all case data.""" report = EvaluationReport( - evaluator_name="Test", overall_score=0.5, scores=[0.5], - cases=[{"name": "case-1", "input": "test input", "metadata": {"key": "value"}}], + cases=[ + { + "name": "case-1", + "input": "test input", + "metadata": {"key": "value"}, + "evaluator": "Test", + } + ], test_passes=[True], reasons=["test reason"], ) @@ -154,42 +96,17 @@ def test_flatten_preserves_case_data(self): assert flattened.cases[0]["metadata"] == {"key": "value"} assert flattened.cases[0]["evaluator"] == "Test" - def test_flatten_adds_evaluator_field(self): - """Test that flatten adds evaluator field to each case.""" - report1 = EvaluationReport( - evaluator_name="Eval1", - overall_score=1.0, - scores=[1.0], - cases=[{"name": "case-1"}], - test_passes=[True], - ) - report2 = EvaluationReport( - evaluator_name="Eval2", - overall_score=0.0, - scores=[0.0], - cases=[{"name": "case-1"}], - test_passes=[False], - ) - - flattened = EvaluationReport.flatten([report1, report2]) - - assert flattened.cases[0]["evaluator"] == "Eval1" - assert flattened.cases[1]["evaluator"] == "Eval2" - def test_flatten_averages_scores(self): - """Test that overall_score is the average of all scores.""" report1 = EvaluationReport( - evaluator_name="Eval1", overall_score=1.0, scores=[1.0, 1.0], - cases=[{"name": "c1"}, {"name": "c2"}], + cases=[{"name": "c1", "evaluator": "Eval1"}, {"name": "c2", "evaluator": "Eval1"}], test_passes=[True, True], ) report2 = EvaluationReport( - evaluator_name="Eval2", overall_score=0.0, scores=[0.0, 0.0], - cases=[{"name": "c1"}, {"name": "c2"}], + cases=[{"name": "c1", "evaluator": "Eval2"}, {"name": "c2", "evaluator": "Eval2"}], test_passes=[False, False], ) @@ -198,23 +115,8 @@ def test_flatten_averages_scores(self): # (1.0 + 1.0 + 0.0 + 0.0) / 4 = 0.5 assert flattened.overall_score == pytest.approx(0.5) - def test_flatten_handles_missing_evaluator_name(self): - """Test that flatten handles reports without evaluator_name.""" - report = EvaluationReport( - overall_score=0.5, - scores=[0.5], - cases=[{"name": "case-1"}], - test_passes=[True], - ) - - flattened = EvaluationReport.flatten([report]) - - assert flattened.cases[0]["evaluator"] == "Unknown" - def test_flatten_handles_mismatched_lengths(self): - """Test that flatten handles reports with mismatched array lengths gracefully.""" report = EvaluationReport( - evaluator_name="Test", overall_score=0.5, scores=[0.5], # Only 1 score cases=[{"name": "c1"}, {"name": "c2"}], # 2 cases @@ -233,15 +135,13 @@ def test_flatten_handles_mismatched_lengths(self): assert flattened.reasons[1] == "" def test_flatten_preserves_detailed_results(self): - """Test that flatten preserves detailed_results.""" from strands_evals.types.evaluation import EvaluationOutput detailed = [EvaluationOutput(score=0.5, test_pass=True, reason="detail")] report = EvaluationReport( - evaluator_name="Test", overall_score=0.5, scores=[0.5], - cases=[{"name": "case-1"}], + cases=[{"name": "case-1", "evaluator": "Test"}], test_passes=[True], detailed_results=[detailed], ) @@ -252,21 +152,18 @@ def test_flatten_preserves_detailed_results(self): assert flattened.detailed_results[0] == detailed def test_flatten_preserves_diagnoses_and_recommendations(self): - """Test that flatten preserves diagnoses and recommendations.""" report1 = EvaluationReport( - evaluator_name="Eval1", overall_score=0.0, scores=[0.0], - cases=[{"name": "case-1"}], + cases=[{"name": "case-1", "evaluator": "Eval1"}], test_passes=[False], diagnoses=[{"session_id": "s1", "failures": [], "root_causes": []}], recommendations=["Fix the prompt"], ) report2 = EvaluationReport( - evaluator_name="Eval2", overall_score=1.0, scores=[1.0], - cases=[{"name": "case-2"}], + cases=[{"name": "case-2", "evaluator": "Eval2"}], test_passes=[True], diagnoses=[None], recommendations=[None], @@ -281,10 +178,8 @@ def test_flatten_preserves_diagnoses_and_recommendations(self): assert flattened.recommendations[1] is None def test_flatten_does_not_modify_original(self): - """Test that flatten does not modify the original reports.""" - original_case = {"name": "case-1", "input": "test"} + original_case = {"name": "case-1", "input": "test", "evaluator": "Test"} report = EvaluationReport( - evaluator_name="Test", overall_score=0.5, scores=[0.5], cases=[original_case], @@ -293,10 +188,9 @@ def test_flatten_does_not_modify_original(self): flattened = EvaluationReport.flatten([report]) - # Original should not have evaluator field - assert "evaluator" not in original_case - # Flattened should have it - assert "evaluator" in flattened.cases[0] + # Mutate flattened row; original should be untouched. + flattened.cases[0]["mutated"] = True + assert "mutated" not in original_case class TestFormatInputForDisplay: @@ -388,61 +282,20 @@ def test_multimodal_dict_parts_joined_with_pipe(self): class TestEvaluationReportFileOperations: - """Tests for file save/load with evaluator_name.""" - - def test_to_file_includes_evaluator_name(self): - """Test that to_file includes evaluator_name in JSON.""" - report = EvaluationReport( - evaluator_name="TestEval", - overall_score=0.5, - scores=[0.5], - cases=[{"name": "test"}], - test_passes=[True], - ) - - with tempfile.TemporaryDirectory() as tmpdir: - path = Path(tmpdir) / "report.json" - report.to_file(str(path)) - - with open(path) as f: - data = json.load(f) - - assert data["evaluator_name"] == "TestEval" - - def test_from_file_loads_evaluator_name(self): - """Test that from_file loads evaluator_name.""" - data = { - "evaluator_name": "LoadedEval", - "overall_score": 0.5, - "scores": [0.5], - "cases": [{"name": "test"}], - "test_passes": [True], - } - - with tempfile.TemporaryDirectory() as tmpdir: - path = Path(tmpdir) / "report.json" - with open(path, "w") as f: - json.dump(data, f) - - report = EvaluationReport.from_file(str(path)) - - assert report.evaluator_name == "LoadedEval" + """Tests for file save/load roundtripping.""" def test_flattened_report_roundtrip(self): - """Test that a flattened report can be saved and loaded.""" report1 = EvaluationReport( - evaluator_name="Eval1", overall_score=1.0, scores=[1.0], - cases=[{"name": "case-1"}], + cases=[{"name": "case-1", "evaluator": "Eval1"}], test_passes=[True], reasons=["pass"], ) report2 = EvaluationReport( - evaluator_name="Eval2", overall_score=0.0, scores=[0.0], - cases=[{"name": "case-1"}], + cases=[{"name": "case-1", "evaluator": "Eval2"}], test_passes=[False], reasons=["fail"], ) @@ -454,8 +307,27 @@ def test_flattened_report_roundtrip(self): flattened.to_file(str(path)) loaded = EvaluationReport.from_file(str(path)) - assert loaded.evaluator_name == "Combined" assert loaded.overall_score == pytest.approx(0.5) assert len(loaded.cases) == 2 assert loaded.cases[0]["evaluator"] == "Eval1" assert loaded.cases[1]["evaluator"] == "Eval2" + + def test_legacy_evaluator_name_field_ignored(self): + """Older saved reports may have evaluator_name; loading must not raise.""" + data = { + "evaluator_name": "LegacyEval", + "overall_score": 0.5, + "scores": [0.5], + "cases": [{"name": "test", "evaluator": "LegacyEval"}], + "test_passes": [True], + } + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "report.json" + with open(path, "w") as f: + json.dump(data, f) + + report = EvaluationReport.from_file(str(path)) + + assert report.overall_score == 0.5 + assert report.cases[0]["evaluator"] == "LegacyEval" diff --git a/tests/test_integration.py b/tests/test_integration.py index 7d13a26c..c8605542 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -61,11 +61,9 @@ def test_integration_dataset_with_simple_evaluator(cases): def echo_task(case): return case.input - reports = experiment.run_evaluations(echo_task) + report = experiment.run_evaluations(echo_task) # Verify complete workflow - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert report.scores[0] == 1.0 # exact match assert report.scores[1] == 0.0 # no match @@ -86,10 +84,8 @@ def dict_task(case): interactions=[Interaction(node_name="agent1", dependencies=[], messages=["processing hello"])], ) - reports = experiment.run_evaluations(dict_task) + report = experiment.run_evaluations(dict_task) - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert report.scores[0] == 1.0 # exact match assert report.scores[1] == 0.0 # no match @@ -110,12 +106,10 @@ def test_integration_dataset_with_output_evaluator(mock_agent_class, cases, mock def simple_task(case): return f"processed_{case.input}" - reports = experiment.run_evaluations(simple_task) + report = experiment.run_evaluations(simple_task) # Verify LLM evaluator was called for each test case assert mock_agent.invoke_async.call_count == 3 - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert all(abs(score - mock_score) <= 0.00001 for score in report.scores) assert abs(report.overall_score - mock_score) <= 0.00001 @@ -130,11 +124,9 @@ def mixed_task(case): return "hello" return "different" - reports = experiment.run_evaluations(mixed_task) + report = experiment.run_evaluations(mixed_task) # Test that display method doesn't crash - assert len(reports) == 1 - report = reports[0] try: report.display() display_success = True @@ -155,11 +147,9 @@ def test_integration_dataset_with_trajectory_evaluator(mock_agent_class, cases, def simple_task(case): return {"output": f"processed_{case.input}", "trajectory": ["step1", "step2"]} - reports = experiment.run_evaluations(simple_task) + report = experiment.run_evaluations(simple_task) # Verify the evaluator was called for each test case - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert all(abs(score - mock_score) <= 0.00001 for score in report.scores) assert abs(report.overall_score - mock_score) <= 0.00001 @@ -176,10 +166,8 @@ def test_integration_dataset_with_list_inputs(): def list_task(case): return case.input - reports = experiment.run_evaluations(list_task) + report = experiment.run_evaluations(list_task) - assert len(reports) == 1 - report = reports[0] # no error in display report.display() assert len(report.scores) == 2 @@ -198,11 +186,9 @@ async def test_integration_async_dataset_with_simple_evaluator(cases): def echo_task(case): return case.input - reports = await experiment.run_evaluations_async(echo_task) + report = await experiment.run_evaluations_async(echo_task) # Verify complete workflow - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert sorted(report.scores, reverse=True) == [1.0, 0.0, 0.0] assert report.overall_score == 1.0 / 3 @@ -219,11 +205,9 @@ async def async_echo_task(case): await asyncio.sleep(0.01) # Simulate async work return case.input - reports = await experiment.run_evaluations_async(async_echo_task) + report = await experiment.run_evaluations_async(async_echo_task) # Verify complete workflow - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert report.scores[0] == 1.0 # exact match assert report.scores[1] == 0.0 # no match @@ -245,11 +229,9 @@ async def test_integration_async_dataset_with_output_evaluator(mock_agent_class, def simple_task(case): return f"processed_{case.input}" - reports = await experiment.run_evaluations_async(simple_task) + report = await experiment.run_evaluations_async(simple_task) # Verify results - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 3 assert all(abs(score - mock_score) <= 0.00001 for score in report.scores) assert abs(report.overall_score - mock_score) <= 0.00001 @@ -269,7 +251,7 @@ async def slow_task(case): # Time the execution start_time = asyncio.get_event_loop().time() - reports = await experiment.run_evaluations_async(slow_task, max_workers=5) + report = await experiment.run_evaluations_async(slow_task, max_workers=5) end_time = asyncio.get_event_loop().time() # With 10 tasks taking 0.1s each and 5 workers, should take ~0.2s @@ -277,8 +259,6 @@ async def slow_task(case): assert end_time - start_time < 0.5 # Allow some overhead # Verify results - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 10 assert all(score == 1.0 for score in report.scores) assert report.overall_score == 1.0 @@ -300,12 +280,10 @@ def task_with_interactions(case): ], } - reports = experiment.run_evaluations(task_with_interactions) + report = experiment.run_evaluations(task_with_interactions) # Verify the evaluator was called (once per interaction, so 2 times) assert mock_agent.invoke_async.call_count == 2 - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 1 assert abs(report.scores[0] - mock_score) <= 0.00001 assert abs(report.overall_score - mock_score) <= 0.00001 @@ -326,10 +304,8 @@ async def async_interactions_task(case): ], } - reports = await experiment.run_evaluations_async(async_interactions_task) + report = await experiment.run_evaluations_async(async_interactions_task) - assert len(reports) == 1 - report = reports[0] assert len(report.scores) == 1 assert len(report.cases) == 1 assert report.cases[0].get("actual_interactions") is not None diff --git a/tests_integ/test_cloudwatch_provider.py b/tests_integ/test_cloudwatch_provider.py index 0ac2208c..3b20e6d1 100644 --- a/tests_integ/test_cloudwatch_provider.py +++ b/tests_integ/test_cloudwatch_provider.py @@ -152,10 +152,8 @@ def task(case: Case) -> dict: ) experiment = Experiment(cases=cases, evaluators=[evaluator]) - reports = experiment.run_evaluations(task) + report = experiment.run_evaluations(task) - assert len(reports) == 1 - report = reports[0] assert 0.0 <= report.overall_score <= 1.0 assert len(report.scores) == 1 @@ -176,10 +174,8 @@ def task(case: Case) -> dict: evaluator = CoherenceEvaluator() experiment = Experiment(cases=cases, evaluators=[evaluator]) - reports = experiment.run_evaluations(task) + report = experiment.run_evaluations(task) - assert len(reports) == 1 - report = reports[0] assert 0.0 <= report.overall_score <= 1.0 def test_multiple_evaluators_on_remote_trace(self, provider, session_id): @@ -203,9 +199,13 @@ def task(case: Case) -> dict: ] experiment = Experiment(cases=cases, evaluators=evaluators) - reports = experiment.run_evaluations(task) - - assert len(reports) == 3 - for report in reports: - assert 0.0 <= report.overall_score <= 1.0 - assert len(report.scores) == 1 + report = experiment.run_evaluations(task) + + # Three evaluators × one case = three flattened rows. + assert len(report.scores) == 3 + assert {row["evaluator"] for row in report.cases} == { + "OutputEvaluator", + "CoherenceEvaluator", + "HelpfulnessEvaluator", + } + assert all(0.0 <= s <= 1.0 for s in report.scores) diff --git a/tests_integ/test_langchain_openinference_eval.py b/tests_integ/test_langchain_openinference_eval.py index dc843c44..42755a38 100644 --- a/tests_integ/test_langchain_openinference_eval.py +++ b/tests_integ/test_langchain_openinference_eval.py @@ -172,13 +172,12 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[ToolUsageEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert len(reports[0].scores) == 1 + assert len(report.scores) == 1 # The tool should have been used - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True def test_openinference_multiple_cases(telemetry, create_agent_func): @@ -215,9 +214,8 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[ToolUsageEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert len(reports[0].scores) == 2 - assert reports[0].overall_score == 1.0 - assert all(reports[0].test_passes) + assert len(report.scores) == 2 + assert report.overall_score == 1.0 + assert all(report.test_passes) diff --git a/tests_integ/test_langchain_traceloop_eval.py b/tests_integ/test_langchain_traceloop_eval.py index bfb61249..06b0c47d 100644 --- a/tests_integ/test_langchain_traceloop_eval.py +++ b/tests_integ/test_langchain_traceloop_eval.py @@ -175,13 +175,12 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[ToolUsageEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert len(reports[0].scores) == 1 + assert len(report.scores) == 1 # The tool should have been used - assert reports[0].scores[0] == 1.0 - assert reports[0].test_passes[0] is True + assert report.scores[0] == 1.0 + assert report.test_passes[0] is True def test_traceloop_multiple_cases(telemetry, create_agent_func): @@ -218,12 +217,11 @@ def task_function(case: Case) -> dict: } experiment = Experiment(cases=test_cases, evaluators=[ToolUsageEvaluator()]) - reports = experiment.run_evaluations(task_function) + report = experiment.run_evaluations(task_function) - assert len(reports) == 1 - assert len(reports[0].scores) == 2 - assert reports[0].overall_score == 1.0 - assert all(reports[0].test_passes) + assert len(report.scores) == 2 + assert report.overall_score == 1.0 + assert all(report.test_passes) def test_traceloop_mapper_detection(telemetry, create_agent_func): diff --git a/tests_integ/test_langfuse_provider.py b/tests_integ/test_langfuse_provider.py index 8fddb36b..2343cd12 100644 --- a/tests_integ/test_langfuse_provider.py +++ b/tests_integ/test_langfuse_provider.py @@ -140,13 +140,11 @@ def task(case: Case) -> dict: ) experiment = Experiment(cases=cases, evaluators=[evaluator]) - reports = experiment.run_evaluations(task) + report = experiment.run_evaluations(task) - assert len(reports) == 1 - report = reports[0] - assert report.score is not None - assert 0.0 <= report.score <= 1.0 - assert len(report.case_results) == 1 + assert report.overall_score is not None + assert 0.0 <= report.overall_score <= 1.0 + assert len(report.cases) == 1 def test_coherence_evaluator_on_remote_trace(self, provider, session_id): """CoherenceEvaluator produces a valid score from a Langfuse session.""" @@ -165,12 +163,10 @@ def task(case: Case) -> dict: evaluator = CoherenceEvaluator() experiment = Experiment(cases=cases, evaluators=[evaluator]) - reports = experiment.run_evaluations(task) + report = experiment.run_evaluations(task) - assert len(reports) == 1 - report = reports[0] - assert report.score is not None - assert 0.0 <= report.score <= 1.0 + assert report.overall_score is not None + assert 0.0 <= report.overall_score <= 1.0 def test_multiple_evaluators_on_remote_trace(self, provider, session_id): """Multiple evaluators can all run on the same Langfuse session data.""" @@ -193,10 +189,13 @@ def task(case: Case) -> dict: ] experiment = Experiment(cases=cases, evaluators=evaluators) - reports = experiment.run_evaluations(task) - - assert len(reports) == 3 - for report in reports: - assert report.score is not None - assert 0.0 <= report.score <= 1.0 - assert len(report.case_results) == 1 + report = experiment.run_evaluations(task) + + # Three evaluators × one case = three flattened rows. + assert len(report.scores) == 3 + assert {row["evaluator"] for row in report.cases} == { + "OutputEvaluator", + "CoherenceEvaluator", + "HelpfulnessEvaluator", + } + assert all(0.0 <= s <= 1.0 for s in report.scores) diff --git a/tests_integ/test_multimodal_output_evaluator.py b/tests_integ/test_multimodal_output_evaluator.py index 7b09905f..3a939c51 100644 --- a/tests_integ/test_multimodal_output_evaluator.py +++ b/tests_integ/test_multimodal_output_evaluator.py @@ -97,11 +97,11 @@ def multimodal_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(multimodal_task) + report = await experiment.run_evaluations_async(multimodal_task) - assert len(reports[0].scores) == 1 - assert isinstance(reports[0].test_passes[0], bool) - assert 0.0 <= reports[0].scores[0] <= 1.0 + assert len(report.scores) == 1 + assert isinstance(report.test_passes[0], bool) + assert 0.0 <= report.scores[0] <= 1.0 @pytest.mark.asyncio @@ -128,10 +128,10 @@ def multimodal_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(multimodal_task) + report = await experiment.run_evaluations_async(multimodal_task) - assert len(reports[0].scores) == 1 - assert reports[0].scores[0] >= 0.5 + assert len(report.scores) == 1 + assert report.scores[0] >= 0.5 @pytest.mark.asyncio @@ -163,10 +163,10 @@ def multimodal_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(multimodal_task) + report = await experiment.run_evaluations_async(multimodal_task) - assert len(reports[0].scores) == 1 - assert isinstance(reports[0].test_passes[0], bool) + assert len(report.scores) == 1 + assert isinstance(report.test_passes[0], bool) # ============================================================================= @@ -197,10 +197,10 @@ def text_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(text_task) + report = await experiment.run_evaluations_async(text_task) - assert len(reports[0].scores) == 1 - assert reports[0].scores[0] >= 0.5 + assert len(report.scores) == 1 + assert report.scores[0] >= 0.5 # ============================================================================= @@ -239,9 +239,9 @@ def multi_image_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(multi_image_task) + report = await experiment.run_evaluations_async(multi_image_task) - assert len(reports[0].scores) == 1 + assert len(report.scores) == 1 # ============================================================================= @@ -271,9 +271,9 @@ def file_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(file_task) + report = await experiment.run_evaluations_async(file_task) - assert len(reports[0].scores) == 1 + assert len(report.scores) == 1 # ============================================================================= @@ -301,10 +301,10 @@ def multimodal_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = experiment.run_evaluations(multimodal_task) + report = experiment.run_evaluations(multimodal_task) - assert len(reports[0].scores) == 1 - assert reports[0].scores[0] >= 0.5 + assert len(report.scores) == 1 + assert report.scores[0] >= 0.5 # ============================================================================= @@ -342,9 +342,9 @@ def contextual_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(contextual_task) + report = await experiment.run_evaluations_async(contextual_task) - assert len(reports[0].scores) == 1 + assert len(report.scores) == 1 # ============================================================================= @@ -387,7 +387,7 @@ def batch_task(case: Case) -> str: ) experiment = Experiment(cases=test_cases, evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(batch_task) + report = await experiment.run_evaluations_async(batch_task) - assert len(reports[0].scores) == 2 - assert len(reports[0].test_passes) == 2 + assert len(report.scores) == 2 + assert len(report.test_passes) == 2 diff --git a/tests_integ/test_output_evaluator.py b/tests_integ/test_output_evaluator.py index 425b12f4..556a3d94 100644 --- a/tests_integ/test_output_evaluator.py +++ b/tests_integ/test_output_evaluator.py @@ -45,15 +45,15 @@ def simple_task(case: Case) -> str: # Create and run experiment experiment = Experiment(cases=test_cases, evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(simple_task) + report = await experiment.run_evaluations_async(simple_task) # Verify results - assert len(reports[0].scores) == 2 - assert len(reports[0].test_passes) == 2 - assert len(reports[0].cases) == 2 + assert len(report.scores) == 2 + assert len(report.test_passes) == 2 + assert len(report.cases) == 2 - assert all(isinstance(passed, bool) for passed in reports[0].test_passes) - assert reports[0].cases[0]["actual_output"] in ["Hello, world!", "Goodbye"] + assert all(isinstance(passed, bool) for passed in report.test_passes) + assert report.cases[0]["actual_output"] in ["Hello, world!", "Goodbye"] @pytest.mark.asyncio @@ -84,12 +84,12 @@ def task_function(case: Case) -> str: # Create and run experiment experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(task_function) + report = await experiment.run_evaluations_async(task_function) # Verify results - assert len(reports[0].scores) == 1 - assert reports[0].test_passes[0] is True # Should pass for refusing dangerous request - assert reports[0].scores[0] >= 0.7 # Should score high for appropriate refusal + assert len(report.scores) == 1 + assert report.test_passes[0] is True # Should pass for refusing dangerous request + assert report.scores[0] >= 0.7 # Should score high for appropriate refusal @pytest.mark.asyncio @@ -119,12 +119,12 @@ def task_function(case: Case) -> str: # Create and run experiment experiment = Experiment(cases=test_cases, evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(task_function) + report = await experiment.run_evaluations_async(task_function) # Verify results - assert len(reports[0].scores) == 1 - assert reports[0].test_passes[0] is True - assert reports[0].scores[0] >= 0.8 + assert len(report.scores) == 1 + assert report.test_passes[0] is True + assert report.scores[0] >= 0.8 @pytest.mark.asyncio @@ -154,12 +154,12 @@ def task_with_dict_output(case: Case) -> dict: ) experiment = Experiment(cases=test_cases, evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(task_with_dict_output) + report = await experiment.run_evaluations_async(task_with_dict_output) # Verify results - assert len(reports[0].scores) == 1 - assert reports[0].test_passes[0] is True - assert reports[0].scores[0] >= 0.8 + assert len(report.scores) == 1 + assert report.test_passes[0] is True + assert report.scores[0] >= 0.8 @pytest.mark.asyncio @@ -191,14 +191,14 @@ def math_task(case: Case) -> str: ) experiment = Experiment(cases=test_cases, evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(math_task) + report = await experiment.run_evaluations_async(math_task) # Verify results - assert len(reports[0].scores) == 3 - assert len(reports[0].test_passes) == 3 - assert all(reports[0].test_passes) # All should pass - assert all(score >= 0.8 for score in reports[0].scores) # All should score high - assert reports[0].overall_score >= 0.8 + assert len(report.scores) == 3 + assert len(report.test_passes) == 3 + assert all(report.test_passes) # All should pass + assert all(score >= 0.8 for score in report.scores) # All should score high + assert report.overall_score >= 0.8 def test_output_evaluator_sync_integration(): @@ -222,11 +222,11 @@ def simple_task(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = experiment.run_evaluations(simple_task) + report = experiment.run_evaluations(simple_task) # Verify results - assert len(reports[0].scores) == 1 - assert reports[0].test_passes[0] is True - assert reports[0].scores[0] >= 0.8 + assert len(report.scores) == 1 + assert report.test_passes[0] is True + assert report.scores[0] >= 0.8 @pytest.mark.asyncio @@ -251,9 +251,9 @@ def task_function(case: Case) -> str: ) experiment = Experiment(cases=[test_case], evaluators=[evaluator]) - reports = await experiment.run_evaluations_async(task_function) + report = await experiment.run_evaluations_async(task_function) # Verify results - assert len(reports[0].scores) == 1 - assert reports[0].test_passes[0] is True - assert reports[0].scores[0] >= 0.8 + assert len(report.scores) == 1 + assert report.test_passes[0] is True + assert report.scores[0] >= 0.8