From 74f90fece80f6225a8bff2595837b64742741d89 Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Sun, 11 Oct 2026 22:22:09 +0200 Subject: [PATCH 1/9] feat(gooddata-eval): record per-run latency and report percentiles The report gave one average latency per run, which says nothing about how slow the slowest answers are. A P99 needs every individual run, but only their sum was kept: passing runs left no latency behind, and the agentic kinds timed an item as a whole. Every run that completes is now timed, passing or not, and each item lists its own `run_latencies_s`. Each run's summary and comparison entry carry `latency_stats_s` -- n, mean, min, P50, P70, P90, P99 and max over those runs -- computed once in core/timing.py with linear interpolation, so P50 is the median and the console, the JSON and any later view cannot disagree. The console prints the percentiles under the summary line, and the multi-model comparison gains P50, P90 and P99 columns. The twelve agentic evaluators take a monotonic timestamp before each run's first message and hand the list out on success, on a failed gate and on the all-ungraded path. A structural test fails any kind that stops doing so: a missing list reads as "not timed", so a kind that forgot it would silently drop out of the percentiles. metric_skill's clock-patching tests gain the two extra readings per run. `avg_latency_s` and every other existing field are unchanged. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 31 ++++++++ .../src/gooddata_eval/cli/agentic_runner.py | 15 ++++ .../gooddata_eval/core/agentic/alert_skill.py | 7 ++ .../core/agentic/anomaly_detection.py | 7 ++ .../core/agentic/conversation.py | 14 ++++ .../core/agentic/dashboard_skill.py | 6 ++ .../core/agentic/general_question.py | 13 +++- .../gooddata_eval/core/agentic/guardrail.py | 13 +++- .../gooddata_eval/core/agentic/kda_skill.py | 7 ++ .../core/agentic/metric_skill.py | 7 ++ .../core/agentic/report_skill.py | 7 ++ .../gooddata_eval/core/agentic/search_tool.py | 9 +++ .../core/agentic/visualization.py | 8 ++ .../src/gooddata_eval/core/agentic/what_if.py | 7 ++ .../core/evaluators/_llm_judge.py | 1 + .../src/gooddata_eval/core/models.py | 6 ++ .../gooddata_eval/core/reporting/console.py | 22 ++++++ .../core/reporting/json_report.py | 8 ++ .../src/gooddata_eval/core/runner.py | 21 ++++++ .../src/gooddata_eval/core/timing.py | 42 +++++++++++ .../tests/test_agentic_alert_skill.py | 3 + .../tests/test_agentic_anomaly_detection.py | 3 + .../tests/test_agentic_conversation.py | 3 + .../tests/test_agentic_dashboard_skill.py | 3 + .../tests/test_agentic_general_question.py | 8 ++ .../tests/test_agentic_guardrail.py | 6 ++ .../tests/test_agentic_kda_skill.py | 3 + .../tests/test_agentic_metric_skill.py | 16 +++- .../tests/test_agentic_report_skill.py | 6 +- .../tests/test_agentic_runner.py | 58 +++++++++++++++ .../tests/test_agentic_search_tool.py | 5 ++ .../tests/test_agentic_visualization.py | 5 ++ .../tests/test_agentic_what_if.py | 3 + .../gooddata-eval/tests/test_html_report.py | 39 ++++++++++ .../gooddata-eval/tests/test_reporting.py | 73 +++++++++++++++++++ packages/gooddata-eval/tests/test_runner.py | 56 ++++++++++++++ packages/gooddata-eval/tests/test_timing.py | 50 +++++++++++++ 37 files changed, 582 insertions(+), 9 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index d6b614955..6555f6089 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -308,6 +308,37 @@ The other six agentic kinds report `0.0` for the first three. Trace linking itse runner rather than by `evaluate_agentic_*`, so a direct library caller sees `langfuse_s: 0.0` even though its linking ran. Pass `TAVERN_E2E_SKIP_TRACE_LINK=1` to opt out of linking altogether. +### Latency distribution + +`avg_latency_s` is one number. To say how slow the tail is, every run that completed is timed and each run +states the distribution of those times: + +```json +"summary": { + "latency_stats_s": {"n": 36, "mean": 18.75, "min": 4.85, "p50": 18.91, "p70": 23.28, "p90": 32.41, "p99": 36.69, "max": 37.51} +} +``` + +`latency_stats_s` is in each run's `summary` and in its `comparison` entry, and is `null` when no run was timed. +Each item also lists its own `run_latencies_s` — one entry per completed run, in run order, passing runs +included (`failed_runs` holds only the failures). The figures are taken over those runs, so an item run three +times contributes three samples; that is the population `avg_latency_s` divides by. + +- **What one run's latency is.** The wall-clock time of that run. For the single-shot kinds it is the chat turn + plus scoring, so it includes the LLM judge and the conversation create/delete. For the agentic kinds it runs + from just before the run's first message to the end of its scoring — every turn, simulated-user reply and + judge call, but not conversation create/delete. The two paths are therefore not timed over exactly the same + span, and for an agentic kind `mean` is at or slightly below `avg_latency_s` (the item's wall clock divided by its + runs), which also covers setup and cleanup. A run that raised was never timed and has no entry; + `agentic_conversation` drives its fixture once and contributes one sample. +- **Percentiles** use linear interpolation between closest ranks (NumPy's default), so `p50` is the median and + the report carries no separate median field. On a small sample the high percentiles sit at or near the + maximum: P99 over fewer than 100 runs is effectively the slowest run, which is why `n` always travels with it. +- **Older reports** have no `latency_stats_s`. The HTML report shows a dash for them and nothing is backfilled. + +The console prints the percentiles beneath each run's summary line — `Latency per run (n=36): P50 18.91s · P70 +23.28s · P90 32.41s · P99 36.69s` — and P50/P90/P99 columns in the multi-model comparison. + --- ## `gd-eval report` diff --git a/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py index 1ead2d118..cfe122674 100644 --- a/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py +++ b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py @@ -371,6 +371,18 @@ def _apply_failed_runs(item_report: ItemReport, source: Any) -> None: item_report.failed_runs = list(failed_runs) +def _apply_run_latencies(item_report: ItemReport, source: Any) -> None: + """Copy the per-run wall-clock times off an outcome or a failure, if the kind timed them. + + Read from both like ``_apply_failed_runs``: a failing item reaches the report through the + exception. A kind that records none leaves the empty default, so the item adds no sample + to the latency distribution rather than a zero. + """ + run_latencies = getattr(source, "run_latencies_s", None) + if run_latencies: + item_report.run_latencies_s = list(run_latencies) + + def _apply_timings(item_report: ItemReport, timings: Any) -> None: """Copy an outcome's phase breakdown onto the item report, if the kind recorded one. @@ -471,6 +483,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport: _apply_timings(item_report, getattr(outcome, "timings", None)) _apply_run_counts(item_report, outcome) _apply_failed_runs(item_report, outcome) + _apply_run_latencies(item_report, outcome) except AssertionError as exc: item_report.gate_passed = False if gated else None item_report.runs = k @@ -481,6 +494,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport: _apply_timings(item_report, getattr(exc, "timings", None)) _apply_run_counts(item_report, exc) _apply_failed_runs(item_report, exc) + _apply_run_latencies(item_report, exc) # Read off the counts, not off the gate: pass^K fails items where runs did pass, # and reporting those as pass_at_k False would contradict the Langfuse score of # the same name. Kinds that report no count read as 0, i.e. a clean failure. @@ -501,6 +515,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport: _apply_timings(item_report, getattr(exc, "timings", None)) _apply_run_counts(item_report, exc) _apply_failed_runs(item_report, exc) + _apply_run_latencies(item_report, exc) # Every run ungraded, by definition of this error -- said explicitly rather than # left to _apply_run_counts, whose source is detail["unscored_runs"] and which a # kind that attaches no diagnostics would leave at 0. diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py index 791810386..ae7bac796 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py @@ -6,6 +6,7 @@ import json import os import re +import time from dataclasses import dataclass, field from typing import Any @@ -491,6 +492,8 @@ class AlertRunResult: # also report operator/threshold/metric/recipients as False. exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED turns_used: int = 0 + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -699,6 +702,7 @@ def _run_once(conv_id: str) -> AlertRunResult: # that simply runs out of range() is correctly labelled without a trailing else. exit_reason = LoopExit.BUDGET_EXHAUSTED turns_used = 0 + run_started = time.monotonic() for _iteration in range(max_iterations): turns_used = _iteration + 1 try: @@ -795,6 +799,7 @@ def _run_once(conv_id: str) -> AlertRunResult: reasoning_step_events=all_reasoning_step_events, exit_reason=exit_reason, turns_used=turns_used, + run_latency_s=time.monotonic() - run_started, ) finally: if alert_id_to_delete: @@ -1011,6 +1016,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -1022,4 +1028,5 @@ def _write_scores(ctx: RunTraceContext) -> None: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/anomaly_detection.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/anomaly_detection.py index e436efaf6..624dbf7f2 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/anomaly_detection.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/anomaly_detection.py @@ -28,6 +28,7 @@ import logging import os import re +import time from dataclasses import dataclass, field from typing import Any @@ -283,6 +284,8 @@ class AnomalyRunResult: response_id: str | None = None tool_call_events: list[ToolCallEvent] = field(default_factory=list) reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list) + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -392,6 +395,7 @@ def _accumulate(result: ChatResult) -> None: all_tool_call_events.extend(result.tool_call_events or []) all_reasoning_step_events.extend(result.reasoning_step_events or []) + run_started = time.monotonic() for iteration in range(max_iterations): try: chat_result = client.send_message(conv_id, current_question) @@ -439,6 +443,7 @@ def _accumulate(result: ChatResult) -> None: response_id=response_id, tool_call_events=all_tool_call_events, reasoning_step_events=all_reasoning_step_events, + run_latency_s=time.monotonic() - run_started, ) try: @@ -647,6 +652,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = len(summary.run_results) exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( @@ -657,4 +663,5 @@ def _write_scores(ctx: RunTraceContext) -> None: response_id=best.response_id, detail=detail, failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/conversation.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/conversation.py index 25e0260d0..8293770e9 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/conversation.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/conversation.py @@ -19,6 +19,7 @@ import json import os import re +import time from dataclasses import dataclass, field from datetime import date from typing import ClassVar, Literal @@ -611,6 +612,9 @@ class ConversationResult: stalled_turns: int = 0 mode: ConversationMode = "legacy" judge_model: str | None = None + # The whole conversation's wall time, from just before its first message to the end of its + # last turn's scoring; conversation create/delete is not part of it. + run_latency_s: float = 0.0 def _metric_creations(tool_call_events: list[ToolCallEvent]) -> list[tuple[str, bool]]: @@ -770,6 +774,7 @@ def run_agentic_conversation( tool_index_offset = 0 reasoning_index_offset = 0 current_context = user_context + run_latency_s = 0.0 try: if initial_conversation_id is not None: @@ -778,10 +783,14 @@ def run_agentic_conversation( conversation_id = client.create_conversation() owns_conversation = True + run_started = time.monotonic() + conversation_swap_s = 0.0 # fresh_conversation_per_turn's delete/create, which is not the run's work for turn_index, turn in enumerate(fixture.turns): if fresh_conversation_per_turn and turn_index > 0: + swap_started = time.monotonic() client.delete_conversation(conversation_id) conversation_id = client.create_conversation() + conversation_swap_s += time.monotonic() - swap_started transcript = [] active_skills = set() # Before the skip below: a turn whose expectation cannot resolve still changed what @@ -1005,6 +1014,8 @@ def run_agentic_conversation( ) ) + # Taken here, not after the finally: its cleanup is not part of the run. + run_latency_s = time.monotonic() - run_started - conversation_swap_s finally: if owns_conversation and conversation_id: client.delete_conversation(conversation_id) @@ -1038,6 +1049,7 @@ def run_agentic_conversation( stalled_turns=stalled, mode=resolved_mode, judge_model=judge.model_name if judged else None, + run_latency_s=run_latency_s, ) @@ -1245,6 +1257,7 @@ def _write_scores(ctx: RunTraceContext) -> None: # for. Saying so explicitly stops the report claiming K runs that never happened. exc.runs_passed = 0 exc.runs_effective = 1 + exc.run_latencies_s = [result.run_latency_s] raise exc return AgenticEvalOutcome( reasoning_steps=result.reasoning_steps, @@ -1253,4 +1266,5 @@ def _write_scores(ctx: RunTraceContext) -> None: detail=detail, runs_passed=1, runs_effective=1, + run_latencies_s=[result.run_latency_s], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/dashboard_skill.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/dashboard_skill.py index c1851383b..667340338 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/dashboard_skill.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/dashboard_skill.py @@ -665,6 +665,8 @@ class DashboardRunResult: tool_call_events: list[ToolCallEvent] = field(default_factory=list) reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list) timings: PhaseTimings = field(default_factory=PhaseTimings) + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -866,6 +868,7 @@ def _execute_single_dashboard_run( tool_index_offset = 0 reasoning_index_offset = 0 + run_started = time.monotonic() for iteration in range(max_iterations): turns += 1 agent_started = time.monotonic() @@ -942,6 +945,7 @@ def _execute_single_dashboard_run( tool_call_events=all_tool_call_events, reasoning_step_events=all_reasoning_step_events, timings=timings, + run_latency_s=time.monotonic() - run_started, ) @@ -1167,6 +1171,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -1177,4 +1182,5 @@ def _write_scores(ctx: RunTraceContext) -> None: detail=detail, timings=item_timings, failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/general_question.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/general_question.py index c773bfdcd..e6526200b 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/general_question.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/general_question.py @@ -89,6 +89,8 @@ class GeneralQuestionResult: judge_error: str | None = None tool_call_events: list[ToolCallEvent] = field(default_factory=list) reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list) + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -151,6 +153,9 @@ def _run_single_general_question( judge_error=verdict.error, tool_call_events=list(chat_result.tool_call_events or []), reasoning_step_events=list(chat_result.reasoning_step_events or []), + # item_started is taken just before the chat call and total_elapsed right after + # scoring, so this is the whole run without a second clock read. + run_latency_s=total_elapsed, ) @@ -222,6 +227,7 @@ def _attach_diagnostics( failed_runs: list[dict], runs_passed: int, runs_effective: int, + run_latencies_s: list[float], ) -> None: """Hang the report payload on a raised error, whichever error it is. @@ -236,6 +242,7 @@ def _attach_diagnostics( error.runs_passed = runs_passed error.runs_effective = runs_effective error.failed_runs = failed_runs + error.run_latencies_s = run_latencies_s def _run_detail(run: GeneralQuestionResult) -> dict: @@ -355,6 +362,7 @@ def _write_scores(ctx: RunTraceContext) -> None: # `runs_passed` is then 0, so nothing here needs a graded run to exist. runs_passed = sum(1 for r in summary.scored_run_results if r.passed) runs_effective = len(summary.run_results) + run_latencies_s = [r.run_latency_s for r in summary.run_results] best = summary.best detail = { @@ -381,7 +389,7 @@ def _write_scores(ctx: RunTraceContext) -> None: f"judge returned no readable verdict for any of the {len(summary.run_results)} run(s): " + " | ".join(unscored) ) - _attach_diagnostics(error, best, detail, failed_runs, runs_passed, runs_effective) + _attach_diagnostics(error, best, detail, failed_runs, runs_passed, runs_effective, run_latencies_s) error.timings = item_timings raise error @@ -390,7 +398,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc = GeneralQuestionAssertionError( f"General question assertion failed. {gate_note} passed={best.passed}. Reasoning: {best.reasoning}" ) - _attach_diagnostics(exc, best, detail, failed_runs, runs_passed, runs_effective) + _attach_diagnostics(exc, best, detail, failed_runs, runs_passed, runs_effective, run_latencies_s) exc.timings = item_timings raise exc return AgenticEvalOutcome( @@ -404,4 +412,5 @@ def _write_scores(ctx: RunTraceContext) -> None: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=run_latencies_s, ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/guardrail.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/guardrail.py index 849a27753..b564fea8d 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/guardrail.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/guardrail.py @@ -3,6 +3,7 @@ from __future__ import annotations +import time from dataclasses import dataclass, field from gooddata_eval.core.agentic._failed_runs import build_failed_runs @@ -84,6 +85,8 @@ class GuardrailResult: # Set when the judge returned something unreadable for THIS run. Excluded from pass@K # and from Langfuse scoring rather than counted as a failure -- see score_run. judge_error: str | None = None + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -118,6 +121,7 @@ def _run_single_guardrail( Extracted so the two call sites below (the first conversation, which may be supplied, and the remaining K-1) cannot drift -- they had already duplicated the whole body once. """ + run_started = time.monotonic() chat_result = client.send_message(conversation_id, question) actual_output = render_answer_text(chat_result) verdict = score_run(judge, input=question, expected_output=expected_output, actual_output=actual_output) @@ -132,6 +136,7 @@ def _run_single_guardrail( tool_call_events=list(chat_result.tool_call_events or []), reasoning_step_events=list(chat_result.reasoning_step_events or []), judge_error=verdict.error, + run_latency_s=time.monotonic() - run_started, ) @@ -203,6 +208,7 @@ def _attach_diagnostics( failed_runs: list[dict], runs_passed: int, runs_effective: int, + run_latencies_s: list[float], ) -> None: """Hang the report payload on a raised error, whichever error it is. @@ -217,6 +223,7 @@ def _attach_diagnostics( error.runs_passed = runs_passed error.runs_effective = runs_effective error.failed_runs = failed_runs + error.run_latencies_s = run_latencies_s def _run_detail(run: GuardrailResult) -> dict: @@ -336,6 +343,7 @@ def _write_scores(ctx: RunTraceContext) -> None: # `runs_passed` is then 0, so nothing here needs a graded run to exist. runs_passed = sum(1 for r in summary.scored_run_results if r.passed) runs_effective = len(summary.run_results) + run_latencies_s = [r.run_latency_s for r in summary.run_results] best = summary.best detail = { @@ -359,7 +367,7 @@ def _write_scores(ctx: RunTraceContext) -> None: f"judge returned no readable verdict for any of the {len(summary.run_results)} run(s): " + " | ".join(unscored) ) - _attach_diagnostics(error, best, detail, failed_runs, runs_passed, runs_effective) + _attach_diagnostics(error, best, detail, failed_runs, runs_passed, runs_effective, run_latencies_s) raise error if not gate_passed(gate, pass_at_k=summary.pass_at_k, pass_power_k=summary.pass_power_k): @@ -367,7 +375,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc = GuardrailAssertionError( f"Guardrail assertion failed. {gate_note} passed={best.passed}. Reasoning: {best.reasoning}" ) - _attach_diagnostics(exc, best, detail, failed_runs, runs_passed, runs_effective) + _attach_diagnostics(exc, best, detail, failed_runs, runs_passed, runs_effective, run_latencies_s) raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -379,4 +387,5 @@ def _write_scores(ctx: RunTraceContext) -> None: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=run_latencies_s, ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/kda_skill.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/kda_skill.py index fade8bff5..dde3b929a 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/kda_skill.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/kda_skill.py @@ -5,6 +5,7 @@ import logging import os +import time from dataclasses import dataclass, field import httpx @@ -199,6 +200,8 @@ class KdaRunResult: # separate a refusal from a run that hit max_iterations while still on track. exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED turns_used: int = 0 + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -299,6 +302,7 @@ def _accumulate(result: ChatResult) -> None: turns = 0 steps = 0 + run_started = time.monotonic() for iteration in range(max_iterations): turns_used = iteration + 1 try: @@ -374,6 +378,7 @@ def _accumulate(result: ChatResult) -> None: reasoning_step_events=all_reasoning_step_events, exit_reason=exit_reason, turns_used=turns_used, + run_latency_s=time.monotonic() - run_started, ) try: @@ -581,6 +586,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -592,4 +598,5 @@ def _write_scores(ctx: RunTraceContext) -> None: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/metric_skill.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/metric_skill.py index ec280c6a9..89172b21e 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/metric_skill.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/metric_skill.py @@ -180,6 +180,8 @@ class MetricRunResult: exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED turns_used: int = 0 timings: PhaseTimings = field(default_factory=PhaseTimings) + # This run's own wall time, from just before its first chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -325,6 +327,7 @@ def _execute_single_metric_run( # Defaults to BUDGET_EXHAUSTED: every other exit assigns explicitly, so a loop that # simply runs out of range() is labelled correctly with no trailing else. exit_reason = LoopExit.BUDGET_EXHAUSTED + run_started = time.monotonic() for _iteration in range(max_iterations): turns += 1 agent_started = time.monotonic() @@ -426,6 +429,7 @@ def _execute_single_metric_run( timings=timings, exit_reason=exit_reason, turns_used=turns, + run_latency_s=time.monotonic() - run_started, ) finally: for metric_id in created_metric_ids: @@ -601,6 +605,7 @@ def _write_scores(ctx: RunTraceContext) -> None: runs_passed = sum(1 for r in summary.run_results if r.metric_created and r.maql_correct) runs_effective = len(summary.run_results) + run_latencies_s = [r.run_latency_s for r in summary.run_results] best = summary.best expected_outputs_list: list[dict] = expected_output if isinstance(expected_output, list) else [expected_output] @@ -651,6 +656,7 @@ def _run_detail(run: MetricRunResult) -> dict: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = run_latencies_s raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -663,4 +669,5 @@ def _run_detail(run: MetricRunResult) -> dict: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=run_latencies_s, ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/report_skill.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/report_skill.py index 86a62bdb0..e1280c60c 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/report_skill.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/report_skill.py @@ -474,6 +474,8 @@ class ReportRunResult: tool_call_events: list[ToolCallEvent] = field(default_factory=list) reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list) timings: PhaseTimings = field(default_factory=PhaseTimings) + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @property def diagnostics(self) -> dict[str, bool]: @@ -546,6 +548,7 @@ def _execute_single_report_run( reasoning_index_offset = 0 first_turn_asked = False + run_started = time.monotonic() for iteration in range(max_iterations): turns += 1 agent_started = time.monotonic() @@ -611,6 +614,7 @@ def _execute_single_report_run( tool_call_events=all_tool_call_events, reasoning_step_events=all_reasoning_step_events, timings=timings, + run_latency_s=time.monotonic() - run_started, ) @@ -799,6 +803,7 @@ def _write_scores(ctx: RunTraceContext) -> None: + " | ".join(unscored) ) exc_judge.timings = item_timings + exc_judge.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc_judge runs_passed = sum(1 for r in summary.run_results if r.evaluation.strict_pass) @@ -852,6 +857,7 @@ def _run_detail(run: ReportRunResult) -> dict[str, Any]: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -862,4 +868,5 @@ def _run_detail(run: ReportRunResult) -> dict[str, Any]: detail=detail, timings=item_timings, failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/search_tool.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/search_tool.py index 2adbde990..ca426b064 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/search_tool.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/search_tool.py @@ -3,6 +3,7 @@ from __future__ import annotations +import time from dataclasses import dataclass, field from gooddata_eval.core.agentic._failed_runs import build_failed_runs @@ -76,6 +77,8 @@ class SearchResult: response_id: str | None = None tool_call_events: list[ToolCallEvent] = field(default_factory=list) reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list) + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -114,6 +117,7 @@ def run_agentic_search_tool( try: conv_id_0 = initial_conversation_id if initial_conversation_id is not None else client.create_conversation() try: + run_started = time.monotonic() chat_result = client.send_message(conv_id_0, question) tcs = chat_result.tool_call_events or [] selected = _tool_selection(tcs) @@ -128,6 +132,7 @@ def run_agentic_search_tool( response_id=chat_result.response_id, tool_call_events=list(chat_result.tool_call_events or []), reasoning_step_events=list(chat_result.reasoning_step_events or []), + run_latency_s=time.monotonic() - run_started, ) ) finally: @@ -137,6 +142,7 @@ def run_agentic_search_tool( for _ in range(1, k): conv_id = client.create_conversation() try: + run_started = time.monotonic() chat_result = client.send_message(conv_id, question) tcs = chat_result.tool_call_events or [] selected = _tool_selection(tcs) @@ -151,6 +157,7 @@ def run_agentic_search_tool( response_id=chat_result.response_id, tool_call_events=list(chat_result.tool_call_events or []), reasoning_step_events=list(chat_result.reasoning_step_events or []), + run_latency_s=time.monotonic() - run_started, ) ) finally: @@ -302,6 +309,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -313,4 +321,5 @@ def _write_scores(ctx: RunTraceContext) -> None: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/visualization.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/visualization.py index 597576001..90d2d06a7 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/visualization.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/visualization.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import time from dataclasses import dataclass, field from gooddata_eval.core.agentic._failed_runs import build_failed_runs @@ -72,6 +73,8 @@ class RunResult: # Why the simulated-user loop stopped. visualization_created=False alone cannot separate # a refusal from a run that hit max_iterations while still on track -- see LoopExit. exit_reason: LoopExit = LoopExit.BUDGET_EXHAUSTED + # This run's own wall time, from just before its first chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -209,6 +212,7 @@ def _execute_single_run( # simply runs out of range() is labelled correctly with no trailing else. exit_reason = LoopExit.BUDGET_EXHAUSTED + run_started = time.monotonic() try: current_result = client.send_message(conversation_id, question) except ChatError as exc: @@ -297,6 +301,7 @@ def _execute_single_run( tool_call_events=all_tool_call_events, reasoning_step_events=all_reasoning_step_events, exit_reason=exit_reason, + run_latency_s=time.monotonic() - run_started, ) @@ -526,6 +531,7 @@ def _write_scores(ctx: RunTraceContext) -> None: runs_passed = sum(1 for r in summary.run_results if r.eval_result.strict_pass) runs_effective = len(summary.run_results) + run_latencies_s = [r.run_latency_s for r in summary.run_results] best = summary.best ev = best.eval_result @@ -588,6 +594,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = runs_effective exc.failed_runs = failed_runs + exc.run_latencies_s = run_latencies_s raise exc return AgenticEvalOutcome( runs_passed=runs_passed, @@ -599,4 +606,5 @@ def _write_scores(ctx: RunTraceContext) -> None: # Also on the success path: pass@K clears the gate with one passing run, so a # 1/3 item reports success while two of its runs failed for reasons worth reading. failed_runs=failed_runs, + run_latencies_s=run_latencies_s, ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/agentic/what_if.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/what_if.py index 3f5f08458..d3421626f 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/what_if.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/what_if.py @@ -22,6 +22,7 @@ import logging import os +import time from dataclasses import dataclass, field from typing import Any @@ -210,6 +211,8 @@ class WhatIfRunResult: response_id: str | None = None tool_call_events: list[ToolCallEvent] = field(default_factory=list) reasoning_step_events: list[ReasoningStepEvent] = field(default_factory=list) + # This run's own wall time, from just before its chat call to the end of its scoring. + run_latency_s: float = 0.0 @dataclass @@ -349,6 +352,7 @@ def _accumulate(result: ChatResult) -> None: all_tool_call_events.extend(result.tool_call_events or []) all_reasoning_step_events.extend(result.reasoning_step_events or []) + run_started = time.monotonic() for iteration in range(max_iterations): try: chat_result = client.send_message(conv_id, current_question) @@ -396,6 +400,7 @@ def _accumulate(result: ChatResult) -> None: response_id=response_id, tool_call_events=all_tool_call_events, reasoning_step_events=all_reasoning_step_events, + run_latency_s=time.monotonic() - run_started, ) try: @@ -612,6 +617,7 @@ def _write_scores(ctx: RunTraceContext) -> None: exc.runs_passed = runs_passed exc.runs_effective = len(summary.run_results) exc.failed_runs = failed_runs + exc.run_latencies_s = [r.run_latency_s for r in summary.run_results] raise exc return AgenticEvalOutcome( @@ -622,4 +628,5 @@ def _write_scores(ctx: RunTraceContext) -> None: response_id=best.response_id, detail=detail, failed_runs=failed_runs, + run_latencies_s=[r.run_latency_s for r in summary.run_results], ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/evaluators/_llm_judge.py b/packages/gooddata-eval/src/gooddata_eval/core/evaluators/_llm_judge.py index 701898068..c32cb3f40 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/evaluators/_llm_judge.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/evaluators/_llm_judge.py @@ -61,6 +61,7 @@ class JudgeResponseError(RuntimeError): runs_passed: int runs_effective: int failed_runs: list[dict] + run_latencies_s: list[float] def _message_content(response: Any) -> str | None: diff --git a/packages/gooddata-eval/src/gooddata_eval/core/models.py b/packages/gooddata-eval/src/gooddata_eval/core/models.py index 1a3c634ef..454dd57af 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/models.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/models.py @@ -409,6 +409,7 @@ class AgenticAssertionError(AssertionError): runs_passed: int runs_effective: int failed_runs: list[dict] + run_latencies_s: list[float] class AgenticEvalOutcome(BaseModel): @@ -440,6 +441,11 @@ class AgenticEvalOutcome(BaseModel): # ``core.agentic._failed_runs.build_failed_runs``; mirrors the single-shot path's # ``ItemReport.failed_runs``, which ``core/runner.py`` fills for the non-agentic kinds. failed_runs: list[dict] = Field(default_factory=list) + # The wall-clock seconds of every run, passing or not, in run order. ``timings`` and + # ``best_run_latency_s`` each describe one number per item; the latency distribution + # needs one per run. Empty means the kind recorded none, which the report reads as "no + # sample", not as zero-second runs. + run_latencies_s: list[float] = Field(default_factory=list) class SummaryInput(BaseModel): diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py b/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py index 31759f7ff..5d903b2d1 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py @@ -6,6 +6,11 @@ from gooddata_eval.core.agentic._gate import gate_label from gooddata_eval.core.runner import EvalReport, ItemReport +from gooddata_eval.core.timing import LATENCY_PERCENTILES + +# The side-by-side table compares the median and the tail; each model's own Latency line +# and the HTML report carry the full P50/P70/P90/P99 set. +COMPARISON_PERCENTILES = (50, 90, 99) def _ungraded_note(item: ItemReport) -> str: @@ -17,6 +22,17 @@ def _ungraded_note(item: ItemReport) -> str: return f"{criteria} criterion(s) ungraded" if criteria else "" +def _latency_line(stats: dict[str, float]) -> str: + """One line of per-run latency percentiles, with the sample size they are taken over. + + Percentiles only, no mean: the Summary line above already carries ``avg .../run``, and a + second average over a slightly different sample would put two near-identical numbers + side by side. + """ + percentiles = " · ".join(f"P{p} {stats[f'p{p}']:.2f}s" for p in LATENCY_PERCENTILES) + return f"Latency per run (n={stats['n']:.0f}): {percentiles}" + + def render_console(report: EvalReport, *, console: Console | None = None) -> str: """Render the report to the console and return the rendered text. @@ -89,6 +105,8 @@ def render_console(report: EvalReport, *, console: Console | None = None) -> str f"({report.skipped} skipped, {report.errored} errored) " f"avg quality {report.avg_quality_score:.0%} in {timing}" ) + if (stats := report.latency_stats) is not None: + out.print(_latency_line(stats)) return out.export_text() if out.record else "" @@ -109,17 +127,21 @@ def render_comparison(reports: list[EvalReport], *, console: Console | None = No table.add_column("Passed") table.add_column("Quality") table.add_column("Avg/run") + for p in COMPARISON_PERCENTILES: + table.add_column(f"P{p}") table.add_column("Total time") for r in reports: evaluated = r.total - r.skipped pass_pct = f"{r.passed / evaluated:.0%}" if evaluated else "—" model_label = f"{r.provider_name}/{r.model}" if r.provider_name else r.model or "?" + stats = r.latency_stats table.add_row( model_label, f"{r.passed}/{r.total} ({pass_pct})", f"{r.avg_quality_score:.0%}", f"{r.avg_latency_s:.2f}s", + *(f"{stats[f'p{p}']:.2f}s" if stats else "—" for p in COMPARISON_PERCENTILES), f"{r.latency_s:.0f}s", ) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py b/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py index 55d922864..88776773a 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py @@ -29,6 +29,10 @@ def _build_run_dict(report: EvalReport) -> dict: "latency_s": round(report.latency_s, 3), "avg_latency_s": round(report.avg_latency_s, 3), "wall_clock_s": round(report.wall_clock_s, 3), + # The distribution of every timed run -- avg_latency_s above is one number for + # the same population and says nothing about its tail. None when no run was + # timed, so a report cannot state a 0s P99 for a run that measured nothing. + "latency_stats_s": report.latency_stats, }, "items": { item.id: { @@ -47,6 +51,9 @@ def _build_run_dict(report: EvalReport) -> dict: "runs": item.runs_total, "latency_s": round(item.latency_s, 3), "avg_latency_s": round(item.avg_latency_s, 3), + # One entry per run that completed, passing or not: the sample the run-level + # percentiles are taken over. latency_s is only their sum. + "run_latencies_s": [round(latency, 3) for latency in item.run_latencies_s], # Beside `runs`, not folded into it: "4 of 5 passed" is a different fact # from pass_at_k and the only one that separates a reliable item from a # coin-flip. pass_power_k is the unanimity flag beside it, and @@ -93,6 +100,7 @@ def _build_comparison_entry(report: EvalReport) -> dict: "avg_quality_score": round(report.avg_quality_score, 4), "avg_latency_s": round(report.avg_latency_s, 3), "total_latency_s": round(report.latency_s, 3), + "latency_stats_s": report.latency_stats, } diff --git a/packages/gooddata-eval/src/gooddata_eval/core/runner.py b/packages/gooddata-eval/src/gooddata_eval/core/runner.py index 7e62dc499..235957dff 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/runner.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/runner.py @@ -12,6 +12,7 @@ from gooddata_eval.core.evaluators import get_evaluator, supported_test_kinds from gooddata_eval.core.evaluators.base import ItemEvaluation from gooddata_eval.core.models import ChatResult, DatasetItem +from gooddata_eval.core.timing import latency_stats class ChatBackend(Protocol): @@ -57,6 +58,11 @@ class ItemReport: # ends up describing the LAST one, which is not necessarily the run best_detail is # about, so those ids cannot be used to pull the trace for a specific failure. failed_runs: list[dict] = field(default_factory=list) + # The wall-clock seconds of every run that completed, in run order -- passing runs too, + # which `failed_runs` deliberately leaves out. `latency_s` is only their sum, so the + # distribution (P50, P90, P99) cannot be recovered from it. A run that raised was never + # timed and has no entry, exactly as it adds nothing to `runs`. + run_latencies_s: list[float] = field(default_factory=list) # Per-phase breakdown of what the item's time was spent on. Additive to latency_s, # which remains the item's own critical path. langfuse_latency_s is # deliberately NOT part of that path -- trace linking runs off it (see @@ -182,6 +188,20 @@ def avg_quality_score(self) -> float: evaluated = [i.quality_score for i in self.items if not i.skipped and i.error is None] return sum(evaluated) / len(evaluated) if evaluated else 0.0 + @property + def run_latencies_s(self) -> list[float]: + """Every timed run across the dataset -- the sample the latency percentiles are over. + + Runs, not items: an item run K times contributes K samples. That is the same unit + `avg_latency_s` divides by, so the two describe one population. + """ + return [latency for item in self.items for latency in item.run_latencies_s] + + @property + def latency_stats(self) -> dict[str, float] | None: + """n, mean, min, P50/P70/P90/P99 and max of `run_latencies_s`; None with no timed run.""" + return latency_stats(self.run_latencies_s) + # A per-run progress callback: (run_index, runs_total, passed, latency_s) -> None RunCallback = Callable[[int, int, bool, float], None] @@ -240,6 +260,7 @@ def _run_one_item( latency = time.perf_counter() - t0 report.runs += 1 report.latency_s += latency + report.run_latencies_s.append(latency) if evaluation.error is not None: report.runs_ungraded += 1 judge_errors.append(evaluation.error) diff --git a/packages/gooddata-eval/src/gooddata_eval/core/timing.py b/packages/gooddata-eval/src/gooddata_eval/core/timing.py index a3c05aef1..83a3a13e0 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/timing.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/timing.py @@ -8,6 +8,8 @@ from __future__ import annotations +import math +from collections.abc import Iterable, Sequence from dataclasses import dataclass from gooddata_eval.core._output import emit_line @@ -72,3 +74,43 @@ def as_dict(self) -> dict[str, float]: def sum_timings(timings: list[PhaseTimings]) -> PhaseTimings: """Total across a list of per-run timings (empty list -> all zeroes).""" return sum(timings, PhaseTimings()) + + +# The percentiles every report states. P50 is the median, so it is the only "median" the +# report carries -- a second field would only repeat it. +LATENCY_PERCENTILES = (50, 70, 90, 99) + + +def percentile(sorted_samples: Sequence[float], p: float) -> float: + """The ``p``-th percentile of an ascending sample, by linear interpolation. + + Linear interpolation between closest ranks (``statistics.quantiles(method="inclusive")``, + NumPy's default), so ``percentile(xs, 50)`` equals ``statistics.median(xs)`` for any + sample. Unlike ``statistics.quantiles`` it is defined for a single sample. On a small + sample the high percentiles sit at or near the maximum -- they cannot report a latency + nobody observed beyond the slowest run. + """ + if not sorted_samples: + raise ValueError("percentile of an empty sample") + rank = (len(sorted_samples) - 1) * p / 100 + lower = math.floor(rank) + upper = min(lower + 1, len(sorted_samples) - 1) + return sorted_samples[lower] + (sorted_samples[upper] - sorted_samples[lower]) * (rank - lower) + + +def latency_stats(samples: Iterable[float]) -> dict[str, float] | None: + """Distribution of per-run latencies in seconds, or ``None`` when there are none. + + ``n`` is the number of runs the figures are over: the percentiles are only as sharp as + that sample, and a reader needs it to judge how far to trust P99. + """ + ordered = sorted(samples) + if not ordered: + return None + return { + "n": len(ordered), + "mean": round(sum(ordered) / len(ordered), 3), + "min": round(ordered[0], 3), + **{f"p{p}": round(percentile(ordered, p), 3) for p in LATENCY_PERCENTILES}, + "max": round(ordered[-1], 3), + } diff --git a/packages/gooddata-eval/tests/test_agentic_alert_skill.py b/packages/gooddata-eval/tests/test_agentic_alert_skill.py index faad70f84..90c6bb60a 100644 --- a/packages/gooddata-eval/tests/test_agentic_alert_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_alert_skill.py @@ -697,6 +697,8 @@ def test_evaluate_agentic_alert_skill_returns_reasoning_steps_on_pass(): "latency_breakdown": [], "tool_calls": [], } + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_alert_skill_attaches_reasoning_steps_to_exception_on_fail(): @@ -743,6 +745,7 @@ def test_evaluate_agentic_alert_skill_attaches_reasoning_steps_to_exception_on_f "latency_breakdown": [], "tool_calls": [], } + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 # --- attributes: a date narrows an alert as a group-by too ---------------------------------- diff --git a/packages/gooddata-eval/tests/test_agentic_anomaly_detection.py b/packages/gooddata-eval/tests/test_agentic_anomaly_detection.py index 84e138fb1..b78322a70 100644 --- a/packages/gooddata-eval/tests/test_agentic_anomaly_detection.py +++ b/packages/gooddata-eval/tests/test_agentic_anomaly_detection.py @@ -319,6 +319,8 @@ def test_detail_reports_the_series_analysed_and_the_flag_count(): assert outcome.detail["asserted"] == ["metric", "granularity"] assert "latency_breakdown" in outcome.detail assert outcome.runs_passed == 1 + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_the_wrong_series_raises_naming_what_was_analysed(): @@ -330,6 +332,7 @@ def test_the_wrong_series_raises_naming_what_was_analysed(): assert "DAY" in str(error) assert error.runs_passed == 0 assert error.conversation_id == "conv-1" + assert len(error.run_latencies_s) == error.runs_effective == 1 def test_a_chart_built_on_an_earlier_turn_is_still_the_one_scored(): diff --git a/packages/gooddata-eval/tests/test_agentic_conversation.py b/packages/gooddata-eval/tests/test_agentic_conversation.py index 4b66e5e9d..76f6b6baa 100644 --- a/packages/gooddata-eval/tests/test_agentic_conversation.py +++ b/packages/gooddata-eval/tests/test_agentic_conversation.py @@ -973,6 +973,8 @@ def test_evaluate_agentic_conversation_returns_reasoning_steps_on_pass(): assert outcome.reasoning_steps == ["thinking about it"] assert outcome.conversation_id == "conv-1" assert outcome.response_id == "resp-1" + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) assert outcome.detail == { "mode": "legacy", "full_skill_coverage": True, @@ -1054,6 +1056,7 @@ def test_evaluate_agentic_conversation_attaches_reasoning_steps_to_exception_on_ assert exc_info.value.reasoning_steps == ["confused thinking"] assert exc_info.value.conversation_id == "conv-1" assert exc_info.value.response_id == "resp-2" + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 assert exc_info.value.detail == { "mode": "legacy", "full_skill_coverage": False, diff --git a/packages/gooddata-eval/tests/test_agentic_dashboard_skill.py b/packages/gooddata-eval/tests/test_agentic_dashboard_skill.py index e949d8a3b..4033120f6 100644 --- a/packages/gooddata-eval/tests/test_agentic_dashboard_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_dashboard_skill.py @@ -1112,6 +1112,7 @@ def test_a_failing_run_raises_with_the_failures_attached(self): assert "the agent never produced a successful draft_dashboard call" in message assert excinfo.value.detail["dashboard_drafted"] is False assert excinfo.value.conversation_id == "conv-1" + assert len(excinfo.value.run_latencies_s) == excinfo.value.runs_effective == 1 def test_a_mis_routed_edit_names_the_skill_the_fixture_asked_for(self): """The builder answering an edit activates dashboard_builder perfectly well. Naming it @@ -1161,6 +1162,8 @@ def test_a_passing_run_returns_the_outcome(self): assert outcome.runs_passed == 1 assert outcome.runs_effective == 1 assert outcome.detail["charts_matched"] is True + assert len(outcome.run_latencies_s) == outcome.runs_effective + assert all(latency >= 0 for latency in outcome.run_latencies_s) def _as_events(raw: list[dict]) -> list[ToolCallEvent]: diff --git a/packages/gooddata-eval/tests/test_agentic_general_question.py b/packages/gooddata-eval/tests/test_agentic_general_question.py index 99dc982cb..cdfc683b8 100644 --- a/packages/gooddata-eval/tests/test_agentic_general_question.py +++ b/packages/gooddata-eval/tests/test_agentic_general_question.py @@ -258,6 +258,10 @@ def test_evaluate_agentic_general_question_returns_reasoning_steps_on_pass(): "actual_output": "42", "latency_breakdown": [], } + # One wall-clock sample per run, passing runs included -- the latency distribution is over + # these, and a kind that forgot to report them would silently drop out of it. + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_general_question_attaches_reasoning_steps_to_exception_on_fail(): @@ -285,6 +289,7 @@ def test_evaluate_agentic_general_question_attaches_reasoning_steps_to_exception "actual_output": "I don't know", "latency_breakdown": [], } + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 def test_evaluate_general_question_defers_langfuse_work_to_the_injected_linker(): @@ -400,6 +405,8 @@ def test_evaluate_general_question_aggregates_timings_across_k_runs(): assert outcome.timings.agent_s == 5.0 # 2.0 + 3.0 assert outcome.timings.judge_s == 1.5 # 1.0 + 0.5 + # Each run's own span, agent and judge both inside it -- not their sum across runs. + assert outcome.run_latencies_s == [3.0, 3.5] def test_evaluate_general_question_attaches_timings_to_the_failure_exception(): @@ -718,3 +725,4 @@ def test_an_item_with_no_gradeable_run_raises_instead_of_reporting_failures(): assert [r["run_index"] for r in err.value.failed_runs] == [1, 2] assert [r["error"] for r in err.value.failed_runs] == ["empty body twice", "no 'score' key"] assert (err.value.runs_passed, err.value.runs_effective) == (0, 2) + assert len(err.value.run_latencies_s) == err.value.runs_effective diff --git a/packages/gooddata-eval/tests/test_agentic_guardrail.py b/packages/gooddata-eval/tests/test_agentic_guardrail.py index dafad8a40..6686353e1 100644 --- a/packages/gooddata-eval/tests/test_agentic_guardrail.py +++ b/packages/gooddata-eval/tests/test_agentic_guardrail.py @@ -157,6 +157,10 @@ def test_evaluate_agentic_guardrail_returns_reasoning_steps_on_pass(): "latency_breakdown": [], "tool_calls": [], } + # One wall-clock sample per run, passing runs included -- the latency distribution is over + # these, and a kind that forgot to report them would silently drop out of it. + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_guardrail_attaches_reasoning_steps_to_exception_on_fail(): @@ -187,6 +191,7 @@ def test_evaluate_agentic_guardrail_attaches_reasoning_steps_to_exception_on_fai "latency_breakdown": [], "tool_calls": [], } + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 # --- the run counts have to reach the report (same predicate pass_at_k uses) --- @@ -354,3 +359,4 @@ def test_all_ungraded_attaches_the_records_to_the_judge_error(): assert [r["conversation_id"] for r in err.failed_runs] == ["conv-1", "conv-2"] assert [r["detail"]["actual_output"] for r in err.failed_runs] == ["answer conv-1", "answer conv-2"] assert (err.runs_passed, err.runs_effective) == (0, 2) + assert len(err.run_latencies_s) == err.runs_effective diff --git a/packages/gooddata-eval/tests/test_agentic_kda_skill.py b/packages/gooddata-eval/tests/test_agentic_kda_skill.py index 573a2a267..07f8bcb64 100644 --- a/packages/gooddata-eval/tests/test_agentic_kda_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_kda_skill.py @@ -1135,6 +1135,8 @@ def test_evaluate_agentic_kda_skill_returns_reasoning_steps_on_pass(): "max_iterations": 1, "latency_breakdown": [], } + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_kda_skill_attaches_reasoning_steps_to_exception_on_fail(): @@ -1173,6 +1175,7 @@ def test_evaluate_agentic_kda_skill_attaches_reasoning_steps_to_exception_on_fai "max_iterations": 1, "latency_breakdown": [], } + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 def test_evaluate_agentic_kda_skill_preserves_reasoning_from_a_chat_error_partial_result(): diff --git a/packages/gooddata-eval/tests/test_agentic_metric_skill.py b/packages/gooddata-eval/tests/test_agentic_metric_skill.py index 37e23f3e2..65f23a39f 100644 --- a/packages/gooddata-eval/tests/test_agentic_metric_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_metric_skill.py @@ -28,16 +28,17 @@ # --- time.monotonic() side effects --------------------------------------------------- # -# metric_skill reads the clock twice per agent turn (start, stop) and twice more per -# simulated-user reply, so a two-turn conversation needs six values. Named rather than +# metric_skill reads the clock twice per agent turn (start, stop), twice more per +# simulated-user reply, and twice per run (start before the first turn, stop after scoring), +# so a two-turn conversation needs eight values. Named rather than # inlined because a single new clock read on the production path breaks every one of these # at once with StopIteration -- and repairing a bare literal means hand-counting clock # reads at each call site. # one turn, metric created straight away: agent 3.0s, no simulated user -_CLOCK_ONE_TURN = [5.0, 8.0] +_CLOCK_ONE_TURN = [5.0, 5.0, 8.0, 8.0] # two turns: agent 1.0s then 0.5s, with a 2.5s simulated-user reply between them -_CLOCK_TWO_TURNS = [20.0, 21.0, 21.0, 23.5, 23.5, 24.0] +_CLOCK_TWO_TURNS = [20.0, 20.0, 21.0, 21.0, 23.5, 23.5, 24.0, 24.0] def _client() -> MagicMock: @@ -726,6 +727,10 @@ def test_evaluate_agentic_metric_skill_returns_reasoning_steps_on_pass(): "latency_breakdown": [], "tool_calls": [], } + # One wall-clock sample per run, passing runs included -- the latency distribution is over + # these, and a kind that forgot to report them would silently drop out of it. + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_metric_skill_attaches_reasoning_steps_to_exception_on_fail(): @@ -761,6 +766,7 @@ def test_evaluate_agentic_metric_skill_attaches_reasoning_steps_to_exception_on_ } assert exc_info.value.conversation_id == "conv-1" assert exc_info.value.response_id is None + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 def test_records_agent_and_simulated_user_latency_separately(): @@ -791,6 +797,8 @@ def test_records_agent_and_simulated_user_latency_separately(): # metric_skill has no judge; conflating its simulated user with one would misreport # a blocking call as a deferrable one. assert timings.judge_s == 0.0 + # The run's wall time spans both turns and the simulated-user reply between them. + assert summary.run_results[0].run_latency_s == 4.0 def test_evaluate_metric_skill_surfaces_timings_on_the_outcome(): diff --git a/packages/gooddata-eval/tests/test_agentic_report_skill.py b/packages/gooddata-eval/tests/test_agentic_report_skill.py index d7cb56641..0a978b8ad 100644 --- a/packages/gooddata-eval/tests/test_agentic_report_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_report_skill.py @@ -404,6 +404,8 @@ def test_a_passing_item_returns_its_checks_and_draft_facts(monkeypatch: pytest.M assert outcome.detail["report_period_correct"] is True assert outcome.detail["summaries_from_data"] == 1 assert outcome.detail["failures"] == [] + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_a_failing_item_raises_with_the_failures(monkeypatch: pytest.MonkeyPatch) -> None: @@ -413,6 +415,7 @@ def test_a_failing_item_raises_with_the_failures(monkeypatch: pytest.MonkeyPatch assert "the agent never produced a successful draft_report call" in str(raised.value) assert raised.value.runs_passed == 0 assert raised.value.conversation_id == "conv-1" + assert len(raised.value.run_latencies_s) == raised.value.runs_effective == 1 def test_a_failing_item_without_report_builder_points_at_the_flags(monkeypatch: pytest.MonkeyPatch) -> None: @@ -745,8 +748,9 @@ def test_an_item_the_judge_could_never_grade_raises(monkeypatch: pytest.MonkeyPa turn = _narrative_turn(_cover_page(), _summary_page("page2", "Revenue grew 12%.", _REVENUE_TREND)) _install_client(monkeypatch, _ScriptedChatClient([turn])) judge = _FakeJudge(error=JudgeResponseError("empty body")) - with pytest.raises(JudgeResponseError, match="no readable verdict"): + with pytest.raises(JudgeResponseError, match="no readable verdict") as raised: evaluate_agentic_report_skill("https://h", "tok", "ws", "q", {"narrative": _NARRATIVE}, judge=judge) + assert len(raised.value.run_latencies_s) == 1 def test_a_narrative_item_passes_with_its_verdict_in_the_detail(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/packages/gooddata-eval/tests/test_agentic_runner.py b/packages/gooddata-eval/tests/test_agentic_runner.py index d62ed4f16..fc1af4885 100644 --- a/packages/gooddata-eval/tests/test_agentic_runner.py +++ b/packages/gooddata-eval/tests/test_agentic_runner.py @@ -968,3 +968,61 @@ def test_run_agentic_items_surfaces_failed_runs_on_a_partial_pass(): report = run_agentic_items([_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") assert report.items[0].pass_at_k is True assert report.items[0].failed_runs == [_A_FAILED_RUN] + + +@pytest.mark.parametrize(("kind", "expected_output", "target"), _ALL_AGENTIC_KIND_CASES) +def test_every_evaluator_hands_out_its_run_latencies(kind, expected_output, target): + """Structural, for the reason the failed_runs check above is: the report test below cans + the outcome and cannot see whether an evaluator filled it. A kind that stops handing out + its per-run times does not fail anything -- it just drops out of the latency percentiles, + quietly, because an empty list reads as "not timed".""" + module = importlib.import_module(getattr(agentic_runner, target).__module__) + source = inspect.getsource(module) + assert "run_latencies_s=" in source, f"{module.__name__} never returns its run latencies on the success path" + assert ".run_latencies_s = " in source, f"{module.__name__} never attaches them to its failure" + + +@pytest.mark.parametrize(("kind", "expected_output", "target"), _ALL_AGENTIC_KIND_CASES) +def test_run_latencies_reach_the_report_for_every_kind(kind, expected_output, target): + item = DatasetItem(id="q1", dataset_name="ds", test_kind=kind, question="q", expected_output=expected_output) + canned = AgenticEvalOutcome(reasoning_steps=["x"], detail={"k": "v"}, run_latencies_s=[4.5, 6.0]) + with patch(f"gooddata_eval.cli.agentic_runner.{target}", return_value=canned): + report = run_agentic_items([item], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") + assert report.items[0].run_latencies_s == [4.5, 6.0] + + +def test_a_kind_that_records_no_run_latencies_adds_no_sample(): + """Absent, not zero: an empty list means "not timed", so the item cannot pull the + percentiles toward 0s.""" + with patch( + "gooddata_eval.cli.agentic_runner.evaluate_agentic_alert_skill", + return_value=AgenticEvalOutcome(reasoning_steps=[], detail={}), + ): + report = run_agentic_items([_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") + assert report.items[0].run_latencies_s == [] + assert report.latency_stats is None + + +def test_run_latencies_are_surfaced_from_the_exception_on_a_failed_item(): + exc = AlertSkillAssertionError("nope") + exc.reasoning_steps = [] + exc.detail = {"alert_created": False} + exc.run_latencies_s = [3.0, 5.0] + with patch("gooddata_eval.cli.agentic_runner.evaluate_agentic_alert_skill", side_effect=exc): + report = run_agentic_items([_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") + assert report.items[0].run_latencies_s == [3.0, 5.0] + + +def test_an_all_ungraded_item_still_reports_what_its_runs_cost(): + """The runs happened and took real time even though no verdict came back, so they belong + in the latency sample -- a broken judge must not make the agent look faster.""" + err = JudgeResponseError("judge returned no readable verdict for any of the 2 run(s)") + err.runs_effective = 2 + err.run_latencies_s = [7.0, 9.5] + with patch("gooddata_eval.cli.agentic_runner.evaluate_agentic_alert_skill", side_effect=err): + report = run_agentic_items( + [_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01", k=2 + ) + item = report.items[0] + assert item.error is not None + assert item.run_latencies_s == [7.0, 9.5] diff --git a/packages/gooddata-eval/tests/test_agentic_search_tool.py b/packages/gooddata-eval/tests/test_agentic_search_tool.py index 975004cd4..e188e7b99 100644 --- a/packages/gooddata-eval/tests/test_agentic_search_tool.py +++ b/packages/gooddata-eval/tests/test_agentic_search_tool.py @@ -209,6 +209,10 @@ def test_evaluate_agentic_search_tool_returns_reasoning_steps_on_pass(): "tool_call_names": ["search_objects"], "latency_breakdown": [], } + # One wall-clock sample per run, passing runs included -- the latency distribution is over + # these, and a kind that forgot to report them would silently drop out of it. + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_search_tool_attaches_reasoning_steps_to_exception_on_fail(): @@ -245,3 +249,4 @@ def test_evaluate_agentic_search_tool_attaches_reasoning_steps_to_exception_on_f "tool_call_names": [], "latency_breakdown": [], } + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 diff --git a/packages/gooddata-eval/tests/test_agentic_visualization.py b/packages/gooddata-eval/tests/test_agentic_visualization.py index fa48b8a91..499f274bf 100644 --- a/packages/gooddata-eval/tests/test_agentic_visualization.py +++ b/packages/gooddata-eval/tests/test_agentic_visualization.py @@ -342,6 +342,10 @@ def test_evaluate_agentic_visualization_returns_reasoning_steps_on_pass(): "latency_breakdown": [], "tool_calls": [], } + # One wall-clock sample per run, passing runs included -- the latency distribution is over + # these, and a kind that forgot to report them would silently drop out of it. + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_evaluate_agentic_visualization_attaches_reasoning_steps_to_exception_on_fail(): @@ -403,6 +407,7 @@ def test_evaluate_agentic_visualization_attaches_reasoning_steps_to_exception_on "latency_breakdown": [], "tool_calls": [], } + assert len(exc_info.value.run_latencies_s) == exc_info.value.runs_effective == 1 # --- turns_used counts every send_message, including the pre-loop one ------------------------ diff --git a/packages/gooddata-eval/tests/test_agentic_what_if.py b/packages/gooddata-eval/tests/test_agentic_what_if.py index 2f45aea4f..db2da73c7 100644 --- a/packages/gooddata-eval/tests/test_agentic_what_if.py +++ b/packages/gooddata-eval/tests/test_agentic_what_if.py @@ -278,6 +278,8 @@ def test_detail_reports_the_adjustments_the_agent_asked_for(): assert outcome.detail["asserted"] == ["metric_id", "scenario_maql"] assert "latency_breakdown" in outcome.detail assert outcome.runs_passed == 1 + assert len(outcome.run_latencies_s) == outcome.runs_effective == 1 + assert all(latency >= 0 for latency in outcome.run_latencies_s) def test_a_wrong_adjustment_raises_naming_what_the_agent_actually_did(): @@ -289,6 +291,7 @@ def test_a_wrong_adjustment_raises_naming_what_the_agent_actually_did(): assert "* 3 *" in str(error) assert error.runs_passed == 0 assert error.conversation_id == "conv-1" + assert len(error.run_latencies_s) == error.runs_effective == 1 def test_a_spec_built_on_an_earlier_turn_is_still_the_one_scored(): diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index b89508365..b267919f2 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -200,3 +200,42 @@ def test_cli_report_command_needs_no_credentials(tmp_path, monkeypatch, redact): argv = ["report", str(src), "-o", str(out)] + (["--redact"] if redact else []) assert main(argv) == 0 assert out.exists() + + +_STATS = {"n": 4, "mean": 2.5, "min": 1.0, "p50": 2.5, "p70": 3.1, "p90": 3.7, "p99": 3.97, "max": 4.0} + + +def _doc_with_latency(model: str) -> dict: + doc = _doc(model, True) + doc["runs"][model]["summary"]["latency_stats_s"] = _STATS + doc["runs"][model]["items"]["item-1"]["run_latencies_s"] = [1.0, 2.0] + doc["comparison"][model]["latency_stats_s"] = _STATS + return doc + + +def test_html_carries_the_latency_distribution_it_was_built_from(): + data = _embedded(build_html(_doc_with_latency("gpt-5"))) + + assert data["runs"]["gpt-5"]["summary"]["latency_stats_s"] == _STATS + assert data["comparison"]["gpt-5"]["latency_stats_s"] == _STATS + assert data["runs"]["gpt-5"]["items"]["item-1"]["run_latencies_s"] == [1.0, 2.0] + + +def test_redact_keeps_the_latency_figures(): + """Latency is a fact about the run, not about our infrastructure -- the same line + redaction already draws for per-item latency.""" + data = _embedded(build_html(_doc_with_latency("gpt-5"), redact=True)) + + run = data["runs"]["Model A"] + assert run["summary"]["latency_stats_s"] == _STATS + assert run["items"]["item-1"]["run_latencies_s"] == [1.0, 2.0] + assert data["comparison"]["Model A"]["latency_stats_s"] == _STATS + + +def test_building_the_html_does_not_invent_stats_for_an_older_report(): + """Files written before gd-eval recorded per-run latency carry no `latency_stats_s`. The + page shows a dash for those; nothing here backfills a figure the run never measured.""" + data = _embedded(build_html(_doc("gpt-5", True))) + + assert "latency_stats_s" not in data["runs"]["gpt-5"]["summary"] + assert "latency_stats_s" not in data["comparison"]["gpt-5"] diff --git a/packages/gooddata-eval/tests/test_reporting.py b/packages/gooddata-eval/tests/test_reporting.py index 8662b62c9..76fef68c8 100644 --- a/packages/gooddata-eval/tests/test_reporting.py +++ b/packages/gooddata-eval/tests/test_reporting.py @@ -488,3 +488,76 @@ def test_json_report_failed_runs_is_empty_for_a_clean_item(): ItemReport(id="i1", dataset_name="d", test_kind="visualization", question="q", pass_at_k=True, runs=2) ) assert build_json_report(report)["items"]["i1"]["failed_runs"] == [] + + +def _sampled_report(model: str, per_item: list[list[float]]) -> EvalReport: + return EvalReport( + model=model, + workspace_id="ws1", + items=[ + ItemReport( + id=f"i{n}", + dataset_name="d", + test_kind="visualization", + question=f"q{n}", + pass_at_k=True, + runs=len(latencies), + latency_s=sum(latencies), + run_latencies_s=latencies, + ) + for n, latencies in enumerate(per_item, start=1) + ], + ) + + +def test_json_report_carries_every_run_latency_and_their_distribution(): + # Two items of two runs each: the sample is the four runs, not the two item averages. + data = build_multi_model_report([_sampled_report("gpt-5.2", [[1.0, 2.0], [3.0, 4.0]])]) + + assert data["runs"]["gpt-5.2"]["items"]["i1"]["run_latencies_s"] == [1.0, 2.0] + expected = {"n": 4, "mean": 2.5, "min": 1.0, "p50": 2.5, "p70": 3.1, "p90": 3.7, "p99": 3.97, "max": 4.0} + assert data["runs"]["gpt-5.2"]["summary"]["latency_stats_s"] == expected + # The comparison entry repeats it: the HTML reads that table and falls back to the summary. + assert data["comparison"]["gpt-5.2"]["latency_stats_s"] == expected + + +def test_json_report_states_no_latency_distribution_when_no_run_was_timed(): + # null, not zeroes: a P99 of 0s would claim a measurement that never happened. + data = build_multi_model_report([_report()]) + + assert data["runs"]["gpt-5.2"]["summary"]["latency_stats_s"] is None + assert data["comparison"]["gpt-5.2"]["latency_stats_s"] is None + assert data["runs"]["gpt-5.2"]["items"]["i1"]["run_latencies_s"] == [] + + +def test_json_report_leaves_the_existing_latency_figures_alone(): + summary = build_json_report(_sampled_report("gpt-5.2", [[1.0, 2.0], [3.0, 4.0]]))["summary"] + + assert summary["latency_s"] == 10.0 + assert summary["avg_latency_s"] == 2.5 + + +def test_console_prints_the_latency_percentiles_beneath_the_summary(): + text = render_console(_sampled_report("gpt-5.2", [[1.0, 2.0], [3.0, 4.0]])) + + assert "Latency per run (n=4): P50 2.50s · P70 3.10s · P90 3.70s · P99 3.97s" in text + assert text.index("Summary:") < text.index("Latency per run") + + +def test_console_omits_the_latency_line_when_no_run_was_timed(): + assert "Latency per run" not in render_console(_report()) + + +def test_comparison_table_sets_the_models_median_and_tail_side_by_side(): + reports = [ + _sampled_report("gpt-5.2", [[10.0], [20.0]]), + _sampled_report("gpt-4o", [[8.0], [12.0]]), + ] + + text = render_comparison(reports, console=Console(record=True, width=200)) + + for column in ("P50", "P90", "P99"): + assert column in text + assert "15.00s" in text # gpt-5.2 median of 10 and 20 + assert "19.00s" in text # gpt-5.2 P90 + assert "10.00s" in text # gpt-4o median of 8 and 12 diff --git a/packages/gooddata-eval/tests/test_runner.py b/packages/gooddata-eval/tests/test_runner.py index c661b0672..881d2f98a 100644 --- a/packages/gooddata-eval/tests/test_runner.py +++ b/packages/gooddata-eval/tests/test_runner.py @@ -3,6 +3,7 @@ import time from unittest.mock import patch +import pytest from gooddata_eval.core.evaluators import supported_test_kinds from gooddata_eval.core.evaluators.base import ItemEvaluation from gooddata_eval.core.models import ChatResult, DatasetItem @@ -510,3 +511,58 @@ def test_an_ungraded_run_is_recorded_as_a_failure_with_its_judge_error(): item = report.items[0] assert item.pass_at_k is True and item.pass_power_k is False assert [r["error"] for r in item.failed_runs] == ["empty body"] + + +# --- every completed run is timed, passing or not: the latency percentiles are over these --- + + +def test_every_run_is_timed_whether_it_passed_or_not(): + report, _ = _run_scripted([_graded(True), _graded(False), _ungraded()], runs=3) + + item = report.items[0] + assert len(item.run_latencies_s) == item.runs == 3 + assert all(latency >= 0 for latency in item.run_latencies_s) + # latency_s is the sum of the same measurements, so the list cannot disagree with it. + assert sum(item.run_latencies_s) == pytest.approx(item.latency_s) + + +def test_a_run_that_raised_is_not_timed_and_is_not_counted(): + class _SecondRunBoomsBackend: + def __init__(self): + self.calls = 0 + + def ask(self, item: DatasetItem) -> ChatResult: + self.calls += 1 + if self.calls == 2: + raise RuntimeError("network down") + return _empty_chat() + + report = run_items([_item()], _SecondRunBoomsBackend(), runs=2) + + item = report.items[0] + assert item.error is not None + # `runs` and the sample agree about what completed: a run with no verdict has no latency. + assert len(item.run_latencies_s) == item.runs == 1 + + +def test_the_report_sample_is_every_run_of_every_item_and_skips_skipped_items(): + skipped = DatasetItem(id="s1", dataset_name="d", test_kind="unknown_kind", question="q", expected_output={}) + backend = _FakeBackend([_chat_with(_viz_obj())]) + + report = run_items([_item(), skipped], backend, runs=3) + + assert len(report.run_latencies_s) == 3 + assert report.items[1].run_latencies_s == [] + stats = report.latency_stats + assert stats is not None + assert stats["n"] == 3 + assert stats["min"] <= stats["p50"] <= stats["p99"] <= stats["max"] + + +def test_a_report_with_no_timed_run_has_no_latency_stats(): + skipped = DatasetItem(id="s1", dataset_name="d", test_kind="unknown_kind", question="q", expected_output={}) + + report = run_items([skipped], _FakeBackend([_empty_chat()]), runs=2) + + assert report.run_latencies_s == [] + assert report.latency_stats is None diff --git a/packages/gooddata-eval/tests/test_timing.py b/packages/gooddata-eval/tests/test_timing.py index 3b31a8411..38bdd601c 100644 --- a/packages/gooddata-eval/tests/test_timing.py +++ b/packages/gooddata-eval/tests/test_timing.py @@ -1,6 +1,7 @@ # (C) 2026 GoodData Corporation """Tests for PhaseTimings and the [timer] diagnostic gate.""" +import statistics import threading from unittest.mock import patch @@ -8,7 +9,9 @@ from gooddata_eval.core.timing import ( TIMERS_ENV_VAR, PhaseTimings, + latency_stats, log_timer, + percentile, sum_timings, timers_enabled, ) @@ -91,3 +94,50 @@ def test_as_dict_rounds_every_phase(): def test_sum_timings_of_nothing_is_all_zeroes(): assert sum_timings([]) == PhaseTimings() + + +def test_percentile_interpolates_between_closest_ranks(): + # rank = 0.9 * 3 = 2.7 -> 3.0 + 0.7 * (4.0 - 3.0) + assert percentile([1.0, 2.0, 3.0, 4.0], 90) == pytest.approx(3.7) + + +@pytest.mark.parametrize("n", [1, 2, 3, 4, 7, 10, 99]) +def test_p50_is_the_median_for_any_sample_size(n): + # The report prints P50 as the median and carries no separate median field, so the + # two must never disagree -- nearest-rank would differ on every even-sized sample. + samples = [float(i * i) for i in range(1, n + 1)] + + assert percentile(samples, 50) == pytest.approx(statistics.median(samples)) + + +def test_percentile_of_a_single_sample_is_that_sample(): + assert percentile([2.5], 99) == 2.5 + + +def test_percentile_of_an_empty_sample_raises(): + with pytest.raises(ValueError, match="empty"): + percentile([], 50) + + +def test_latency_stats_of_nothing_is_none(): + # None rather than zeroes: a run with no timed runs has no latency, not a 0s one. + assert latency_stats([]) is None + + +def test_latency_stats_orders_an_unsorted_sample_and_reports_every_figure(): + stats = latency_stats([4.0, 1.0, 3.0, 2.0]) + + assert stats == {"n": 4, "mean": 2.5, "min": 1.0, "p50": 2.5, "p70": 3.1, "p90": 3.7, "p99": 3.97, "max": 4.0} + + +def test_latency_stats_never_decreases_from_p50_to_max(): + stats = latency_stats([0.4, 12.0, 3.3, 3.3, 7.9, 0.9, 21.5]) + + ladder = [stats[k] for k in ("min", "p50", "p70", "p90", "p99", "max")] + assert ladder == sorted(ladder) + + +def test_latency_stats_of_one_run_collapses_to_that_run(): + stats = latency_stats([3.25]) + + assert stats == {"n": 1, "mean": 3.25, "min": 3.25, "p50": 3.25, "p70": 3.25, "p90": 3.25, "p99": 3.25, "max": 3.25} From f135615e5f3f382af4d1e64621ea733cbd9dd8ef Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Sun, 11 Oct 2026 22:22:42 +0200 Subject: [PATCH 2/9] feat(gooddata-eval): restyle HTML report, show latency percentiles The report used an ad-hoc palette that looked like no GoodData product, and showed only an average latency even though the JSON now carries the full per-run distribution. The page follows the product UI: a black 44px header with a lime rule, cyan primary, the #464e56 text and #dde4eb border scale, 3px controls and status chips tinted at 15% alpha. It keeps a system font stack rather than embedding Avenir, so the file stays self-contained and opens offline. The text steps of the status colours and the secondary grey are one step darker than the product's, because the product values fall below 4.5:1 on tinted rows and conversation turns; every text/background pair now clears AA. Each run card gets a Latency per run panel -- P50 (median), P70, P90, P99, average, maximum and the number of runs -- with a strip placing the percentiles between the fastest and slowest run, and the comparison table gains the same columns. All of it is read from `latency_stats_s`; the page computes no percentile, and a report written before that field shows a dash. The comparison table now scrolls inside its own box instead of clipping in a narrow window, and rows and sort headers can be operated from the keyboard. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 8 +- .../core/reporting/report_template.html | 250 +++++++++++++----- .../gooddata-eval/tests/test_html_report.py | 24 ++ 3 files changed, 212 insertions(+), 70 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index 6555f6089..5164594a5 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -345,7 +345,8 @@ The console prints the percentiles beneath each run's summary line — `Latency Turns JSON report(s) into one HTML file you can actually navigate. No server, no credentials, no external assets — it opens over `file://`, attaches to a Jira issue and -survives a Slack thread. +survives a Slack thread. It follows GoodData's product look (colours, type, status chips), using a system +font stack rather than an embedded web font so the file stays self-contained. ```bash # one run @@ -366,7 +367,10 @@ gd-eval report results.json -o customer.html --redact The report is a *view* over the JSON — it computes no numbers of its own. It gives you: -- **Run cards and a comparison table** — pass rate, quality, latency per run. +- **Run cards and a comparison table** — pass rate, quality and latency per run. Each run card has a + **Latency per run** panel: P50 (the median), P70, P90 and P99, with the average, the maximum and the number + of runs they are over, and a strip marking where the percentiles fall between the fastest and slowest run. + The comparison table carries the same figures side by side. They are read from `latency_stats_s`. - **An item table** with a pass/fail column per run, so a model or run-over-run regression is one glance rather than a hand-assembled spreadsheet. - **An expression filter** for cross-cutting questions the fixed filters can't diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index 209124775..215c26e8d 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -6,96 +6,145 @@ gd-eval report
+ gd-eval +

@@ -107,17 +156,17 @@

Items

- - - + + - - + +
@@ -137,6 +186,8 @@

Items

const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); const num = (v, d = 2) => (typeof v === 'number' ? v.toFixed(d) : '–'); const pct = (v) => (typeof v === 'number' ? (v * 100).toFixed(1) + '%' : '–'); +// Seconds, two decimals under 10s and one above, so a 117.8s tail and a 0.84s median both read. +const secs = (v) => (typeof v === 'number' ? v.toFixed(v < 10 ? 2 : 1) + 's' : '–'); // ---------- rows: one per item id, holding that id's result in every run ---------- const rows = []; @@ -156,12 +207,45 @@

Items

} const statusOf = (item) => !item ? 'none' : item.skipped ? 'skip' : item.error ? 'err' : item.pass_at_k ? 'pass' : 'fail'; const LBL = { pass: 'PASS', fail: 'FAIL', err: 'ERR', skip: 'SKIP', none: '–' }; +const chipHtml = (s) => `${LBL[s]}`; // ---------- header ---------- -$('#title').textContent = DATA.title || 'gd-eval report'; +// The wordmark already says gd-eval, so the default title would only repeat it. +$('#title').textContent = !DATA.title || DATA.title === 'gd-eval report' ? 'Evaluation report' : DATA.title; $('#meta').innerHTML = `${rows.length} item(s) · ${LABELS.length} run(s) · generated ${esc(DATA.generated_at)}` + (DATA.redacted ? 'REDACTED' : ''); +// ---------- latency: read from the report, never recomputed here ---------- +// The percentiles are produced by gd-eval when the JSON is written, so this page cannot +// disagree with the console or the JSON. A report from an older gd-eval has none and shows a dash. +function latStats(label) { + const c = DATA.comparison[label] || {}; + const s = (DATA.runs[label].summary || {}); + return c.latency_stats_s || s.latency_stats_s || null; +} +function stripHtml(st) { + const span = st.max - st.min; + const at = (v) => (span > 0 ? ((v - st.min) / span) * 100 : 50); + const ticks = [['50', st.p50], ['70', st.p70], ['90', st.p90], ['99', st.p99]]; + return `
min ${secs(st.min)}band P50–P90max ${secs(st.max)}
`; +} +function latencyHtml(label) { + const st = latStats(label), s = DATA.runs[label].summary || {}; + if (!st) { + return `

Latency per run

+
no per-run latencies in this report · ${num(s.avg_latency_s)}s avg
`; + } + const tile = (k, v, sub) => `
P${k}${secs(v)}${sub}
`; + return `

Latency per run

+
${tile(50, st.p50, 'median')}${tile(70, st.p70, '')}${tile(90, st.p90, '')}${tile(99, st.p99, '')}
+
avg ${secs(st.mean)} · max ${secs(st.max)} · n = ${st.n} run${st.n === 1 ? '' : 's'}
+ ${stripHtml(st)}
`; +} + // ---------- summary cards ---------- $('#summary').innerHTML = '

Runs

' + LABELS.map((label) => { const s = DATA.runs[label].summary || {}; @@ -169,24 +253,41 @@

Items

return `
${esc(label)}
${pct(rate)}
${s.passed ?? 0}/${s.total ?? 0} passed · ${s.errored ?? 0} err · ${s.skipped ?? 0} skip
-
${num(s.avg_latency_s)}s avg latency
-
`; +
+ ${latencyHtml(label)}
`; }).join('') + '
'; +{ + const stats = LABELS.map(latStats); + const notes = []; + // Percentiles are only as sharp as the sample: below 100 runs there is no P99 beyond the slowest run. + if (stats.some((st) => st && st.n < 100)) notes.push('With fewer than 100 runs, P99 is effectively the slowest run.'); + if (stats.some((st) => !st)) notes.push('Runs without percentiles come from a report written before gd-eval recorded per-run latency.'); + if (notes.length) $('#summary').insertAdjacentHTML('beforeend', `
${notes.map(esc).join(' ')}
`); +} // ---------- comparison ---------- if (LABELS.length > 1) { + const st = (l) => latStats(l); + // Percentile cells repeat the run's sample size in a tooltip: P99 over 12 runs is not P99 over 400. + const pc = (k) => (l) => (st(l) ? `${secs(st(l)['p' + k])}` : '–'); const cols = [ ['Run', (l) => esc(l), ''], ['Pass rate', (l) => pct(cmp(l).pass_rate), 'num'], ['Passed', (l) => `${cmp(l).passed ?? 0}/${cmp(l).total ?? 0}`, 'num'], ['Avg quality', (l) => pct(cmp(l).avg_quality_score), 'num'], - ['Avg latency', (l) => num(cmp(l).avg_latency_s) + 's', 'num'], + ['Avg latency', (l) => (st(l) ? secs(st(l).mean) : num(cmp(l).avg_latency_s) + 's'), 'num'], + ['P50 (median)', pc(50), 'num'], + ['P70', pc(70), 'num'], + ['P90', pc(90), 'num'], + ['P99', pc(99), 'num'], ['Total latency', (l) => num(cmp(l).total_latency_s, 1) + 's', 'num'], ]; - $('#comparison').innerHTML = '

Comparison

' + + // Wrapped like the item table: with several runs it is wider than a narrow window, and it + // must scroll inside its own box rather than clip. + $('#comparison').innerHTML = '

Comparison

' + cols.map(([h, , c]) => ``).join('') + '' + LABELS.map((l) => '' + cols.map(([, f, c]) => ``).join('') + '').join('') + - '
${h}
${f(l)}
'; + ''; } function cmp(label) { const c = DATA.comparison[label]; @@ -253,29 +354,42 @@

Items

.concat(LABELS.map((l) => [l, l])) .concat([['latency', 'Latency']]); $('#items thead').innerHTML = '' + heads.map(([k, h]) => - `${esc(h)}${sortKey === k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; + `${esc(h)}${sortKey === k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; $('#items tbody').innerHTML = list.map((r) => { const it = r.per[focus] || {}; - return ` + return ` ${esc(r.id)} ${esc(r.kind)} ${esc(r.question)}` + - LABELS.map((l) => { const s = statusOf(r.per[l]); return `${LBL[s]}`; }).join('') + + LABELS.map((l) => `${chipHtml(statusOf(r.per[l]))}`).join('') + `${num(it.avg_latency_s)}s`; }).join(''); $('#noitems').hidden = list.length > 0; } -$('#items thead').addEventListener('click', (e) => { - const k = e.target.closest('th')?.dataset.k; - if (!k) return; +function sortBy(k) { sortDir = sortKey === k ? -sortDir : 1; sortKey = k; render(); +} +function openRow(id) { + selected = id; render(); openDrawer(byId.get(id)); +} +$('#items thead').addEventListener('click', (e) => { + const k = e.target.closest('th')?.dataset.k; + if (k) sortBy(k); +}); +$('#items thead').addEventListener('keydown', (e) => { + const k = e.target.closest('th')?.dataset.k; + if (k && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); sortBy(k); } }); $('#items tbody').addEventListener('click', (e) => { const id = e.target.closest('tr')?.dataset.id; - if (id) { selected = id; render(); openDrawer(byId.get(id)); } + if (id) openRow(id); +}); +$('#items tbody').addEventListener('keydown', (e) => { + const id = e.target.closest('tr')?.dataset.id; + if (id && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openRow(id); } }); for (const id of ['#q', '#expr', '#kind', '#status']) $(id).addEventListener('input', render); $('#focus').addEventListener('change', () => { @@ -290,13 +404,13 @@

Items

const it = r.per[focus]; const d = $('#drawer'); d.classList.add('open'); - if (!it) { d.innerHTML = `

${esc(r.id)}

Not present in ${esc(focus)}.

`; return; } + if (!it) { d.innerHTML = `

${esc(r.id)}

Not present in ${esc(focus)}.

`; return; } const st = statusOf(it); - d.innerHTML = ` + d.innerHTML = `

${esc(focus)}

${esc(r.id)}

${esc(r.question)}

-
${LBL[st]} +
${chipHtml(st)} ${esc(r.kind)} ${it.runs ?? 0} run(s) ${num(it.best_run_latency_s ?? it.avg_latency_s)}s diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index b267919f2..851bd680d 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -232,6 +232,30 @@ def test_redact_keeps_the_latency_figures(): assert data["comparison"]["Model A"]["latency_stats_s"] == _STATS +def test_the_page_reads_every_reported_percentile_rather_than_computing_its_own(): + """The report is a view over the JSON. The page names the four percentiles and reads + them from `latency_stats_s`; it holds no percentile arithmetic that could disagree with + the console or the JSON.""" + html = build_html(_doc_with_latency("gpt-5")) + + assert "latency_stats_s" in html + for label in ("P50", "P70", "P90", "P99"): + assert label in html + assert "sort(" not in html.split("// ---------- latency")[1].split("// ---------- summary cards")[0] + + +def test_html_uses_the_goodata_tokens_and_loads_no_web_font(): + html = build_html(_doc("gpt-5", True)) + + for token in ("#14b2e2", "#dafe85", "#464e56", "#dde4eb", "#f5f8fa"): + assert token in html, f"design token {token} missing" + assert '"Avenir Next", Avenir' in html + # Self-contained: a web font, import or remote stylesheet would break file:// and offline use. + assert "@font-face" not in html + assert "@import" not in html + assert "url(" not in html + + def test_building_the_html_does_not_invent_stats_for_an_older_report(): """Files written before gd-eval recorded per-run latency carry no `latency_stats_s`. The page shows a dash for those; nothing here backfills a figure the run never measured.""" From f1ba69e2219a68844ba482bc6cc1b46be07b3d64 Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Sun, 11 Oct 2026 22:46:57 +0200 Subject: [PATCH 3/9] feat(gooddata-eval): split latency by single-shot vs multi-turn The latency percentiles covered every run as one population, which mixed a single chat turn with whole conversations and counted the LLM judge and conversation set-up as GoodData's time. gen-ai starts one trace per chat message, so the unit that describes the agent is the trace. Items now fall into two groups by eval kind. Single-shot kinds send one message per run, so a run is one trace and its latency is that turn's response time -- the main figure. Multi-turn kinds drive a conversation and are reported both per trace (each turn) and per full run (the whole conversation's wall clock). The JSON replaces `latency_stats_s` with `by_mode` on each run's summary and comparison entry, and every item states its `mode` and `trace_latencies_s`. Traces are recorded once, where the chat client completes a turn, into a context-scoped recorder that the single-shot and agentic runners open per item. No evaluator has to remember to hand them out, and concurrent items cannot mix. The summary endpoint is timed the same way. `run_latencies_s` stays and now feeds the multi-turn per-run figure. A guard test fails when a new agentic kind has not been classified. The console prints pass counts and one latency line per population, and the comparison table shows single-shot trace P50, P90 and P99. The HTML report gets a Single-shot section, then a Multi-turn section with both views, each a table with a range strip, plus a mode column and filter on the items table. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 70 +++++--- .../src/gooddata_eval/cli/agentic_runner.py | 6 + .../src/gooddata_eval/core/chat/sse_client.py | 8 +- .../src/gooddata_eval/core/interaction.py | 57 +++++++ .../gooddata_eval/core/reporting/console.py | 52 ++++-- .../core/reporting/json_report.py | 20 ++- .../core/reporting/report_template.html | 151 ++++++++++-------- .../src/gooddata_eval/core/runner.py | 47 ++++-- .../gooddata_eval/core/summary/http_client.py | 12 +- .../src/gooddata_eval/core/timing.py | 39 +++++ .../tests/test_agentic_runner.py | 76 ++++++++- .../gooddata-eval/tests/test_html_report.py | 68 ++++---- .../gooddata-eval/tests/test_interaction.py | 35 ++++ .../gooddata-eval/tests/test_reporting.py | 144 ++++++++++++----- packages/gooddata-eval/tests/test_runner.py | 140 ++++++++++++++-- .../gooddata-eval/tests/test_sse_client.py | 34 ++++ .../tests/test_summary_client.py | 6 + packages/gooddata-eval/tests/test_timing.py | 52 ++++++ 18 files changed, 807 insertions(+), 210 deletions(-) create mode 100644 packages/gooddata-eval/src/gooddata_eval/core/interaction.py create mode 100644 packages/gooddata-eval/tests/test_interaction.py diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index 5164594a5..2d9184c03 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -308,36 +308,53 @@ The other six agentic kinds report `0.0` for the first three. Trace linking itse runner rather than by `evaluate_agentic_*`, so a direct library caller sees `langfuse_s: 0.0` even though its linking ran. Pass `TAVERN_E2E_SKIP_TRACE_LINK=1` to opt out of linking altogether. -### Latency distribution +### Latency: single-shot and multi-turn -`avg_latency_s` is one number. To say how slow the tail is, every run that completed is timed and each run -states the distribution of those times: +`avg_latency_s` is one number over a mixed population. The report instead keeps two kinds of item apart, by +their eval kind, and states latency for each: + +- **Single-shot** — kinds that send exactly one chat message per run: every non-agentic kind, plus + `agentic_general_question`, `agentic_guardrail` and `agentic_search`. +- **Multi-turn** — kinds that drive a simulated user or fixture turns: `agentic_visualization` (and + `vis_agentic`), `agentic_metric_skill`, `agentic_alert_skill`, `agentic_dashboard_skill`, + `agentic_report_skill`, `agentic_kda_skill`, `agentic_anomaly_detection`, `agentic_what_if` and + `agentic_conversation`. ```json "summary": { - "latency_stats_s": {"n": 36, "mean": 18.75, "min": 4.85, "p50": 18.91, "p70": 23.28, "p90": 32.41, "p99": 36.69, "max": 37.51} + "by_mode": { + "single_shot": {"total": 12, "passed": 10, + "traces_s": {"n": 24, "mean": 5.39, "min": 3.88, "p50": 5.33, "p70": 6.23, "p90": 6.82, "p99": 7.18, "max": 7.22}}, + "multi_turn": {"total": 6, "passed": 4, + "traces_s": {"n": 45, "mean": 6.91, "min": 2.78, "p50": 6.52, "p70": 8.03, "p90": 10.79, "p99": 13.57, "max": 14.25}, + "runs_s": {"n": 15, "mean": 23.55, "min": 12.64, "p50": 21.23, "p70": 28.52, "p90": 33.02, "p99": 36.94, "max": 37.51}} + } } ``` -`latency_stats_s` is in each run's `summary` and in its `comparison` entry, and is `null` when no run was timed. -Each item also lists its own `run_latencies_s` — one entry per completed run, in run order, passing runs -included (`failed_runs` holds only the failures). The figures are taken over those runs, so an item run three -times contributes three samples; that is the population `avg_latency_s` divides by. - -- **What one run's latency is.** The wall-clock time of that run. For the single-shot kinds it is the chat turn - plus scoring, so it includes the LLM judge and the conversation create/delete. For the agentic kinds it runs - from just before the run's first message to the end of its scoring — every turn, simulated-user reply and - judge call, but not conversation create/delete. The two paths are therefore not timed over exactly the same - span, and for an agentic kind `mean` is at or slightly below `avg_latency_s` (the item's wall clock divided by its - runs), which also covers setup and cleanup. A run that raised was never timed and has no entry; - `agentic_conversation` drives its fixture once and contributes one sample. +`by_mode` is in each run's `summary` and in its `comparison` entry. `total` and `passed` count the evaluated +items of that mode (skipped ones belong to neither). Every distribution is `null` when nothing was timed. +Each item also states its `mode`, its `trace_latencies_s` and its `run_latencies_s`. + +- **Per trace is the main figure.** gen-ai starts one trace per chat message, so a trace is one chat turn. + Its latency is that turn's response time, from sending the message to the end of its stream + (`turn_wall_clock_sec`), recorded where the chat client completes the turn. It holds none of the LLM judge, + the simulated user or conversation create/delete. `dashboard_summary` is timed the same way around its one + request. A turn that raised was never completed and has no entry. +- **Single-shot** runs are exactly one trace, so `traces_s` is also the latency of a run. +- **Multi-turn** runs span several traces, so they get both views: `traces_s` over every turn of every + conversation (comparable with single-shot), and `runs_s`, the wall clock of each whole conversation from just + before its first message to the end of its scoring — every turn, simulated-user reply and judge call, but not + conversation create/delete. `agentic_conversation` drives its fixture once and contributes one run per item. + The two single-shot and multi-turn populations are never mixed. - **Percentiles** use linear interpolation between closest ranks (NumPy's default), so `p50` is the median and the report carries no separate median field. On a small sample the high percentiles sit at or near the - maximum: P99 over fewer than 100 runs is effectively the slowest run, which is why `n` always travels with it. -- **Older reports** have no `latency_stats_s`. The HTML report shows a dash for them and nothing is backfilled. + maximum: P99 over fewer than 100 samples is effectively the slowest one, which is why `n` always travels with it. +- **Older reports** have no `by_mode`. The HTML report says so and nothing is backfilled. -The console prints the percentiles beneath each run's summary line — `Latency per run (n=36): P50 18.91s · P70 -23.28s · P90 32.41s · P99 36.69s` — and P50/P90/P99 columns in the multi-model comparison. +`avg_latency_s` and the other existing fields are unchanged. The console prints each run's pass counts per +mode and one `Latency, (n=...)` line per distribution beneath its summary line, and Trace +P50/P90/P99 columns (single-shot) in the multi-model comparison. --- @@ -367,12 +384,15 @@ gd-eval report results.json -o customer.html --redact The report is a *view* over the JSON — it computes no numbers of its own. It gives you: -- **Run cards and a comparison table** — pass rate, quality and latency per run. Each run card has a - **Latency per run** panel: P50 (the median), P70, P90 and P99, with the average, the maximum and the number - of runs they are over, and a strip marking where the percentiles fall between the fastest and slowest run. - The comparison table carries the same figures side by side. They are read from `latency_stats_s`. +- **Run cards and a comparison table** — pass rate and quality per run, with the pass counts split by + single-shot and multi-turn and the single-shot per-trace median and tail on each card. +- **Latency sections** — **Single-shot · latency per trace** first, as the main figure, then **Multi-turn**, which + shows both per trace and per full run. Each is a table with one row per run: P50 (the median), P70, P90, P99, + the average, the maximum, the number of samples and a strip marking where the percentiles fall between the + fastest and slowest sample. They are read from `by_mode`, never recomputed in the page. - **An item table** with a pass/fail column per run, so a model or run-over-run - regression is one glance rather than a hand-assembled spreadsheet. + regression is one glance rather than a hand-assembled spreadsheet. A mode column and filter separate + single-shot from multi-turn items. - **An expression filter** for cross-cutting questions the fixed filters can't anticipate, e.g. `d.filter_ranking_score === false` or `d.expected_metric_uris.length > 1 && !d.metrics_correct`. Available variables: diff --git a/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py index cfe122674..3f5ecb384 100644 --- a/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py +++ b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py @@ -28,6 +28,7 @@ from gooddata_eval.core.evaluators.visualization import requires_execution_of from gooddata_eval.core.models import AgenticEvalOutcome, CreatedVisualization, DatasetItem from gooddata_eval.core.runner import EvalReport, ItemReport +from gooddata_eval.core.timing import TraceRecorder class _LfKw(TypedDict, total=False): @@ -449,6 +450,10 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport: # None, not False, for the kinds _dispatch_agentic passes no gate to: ItemReport.passed # then falls back to pass_at_k and gate_passed keeps meaning "a gate ran". gated = item.test_kind not in UNGATED_AGENTIC_TEST_KINDS + # Started in this worker, where the item runs, so a concurrent item cannot write into it. + # Every kind reaches the chat client the same way, which is why the per-trace latencies are + # collected here once rather than handed out by each evaluator. + traces = TraceRecorder() t0 = time.perf_counter() try: outcome = _dispatch_agentic( @@ -531,6 +536,7 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport: _apply_timings(item_report, getattr(exc, "timings", None)) finally: item_report.latency_s = time.perf_counter() - t0 + item_report.trace_latencies_s = traces.stop() if on_item_done is not None: try: diff --git a/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py b/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py index e086db8ce..5f3dd6ea2 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py @@ -27,6 +27,7 @@ from gooddata_eval.core.config import ReasoningEffort, normalize_reasoning_effort from gooddata_eval.core.models import ChatResult, DatasetItem +from gooddata_eval.core.timing import note_trace_latency _log = logging.getLogger(__name__) @@ -587,7 +588,12 @@ def _do() -> ChatResult: result.turn_wall_clock_sec = time.monotonic() - t0 return result - return _retry_transient(_do, is_retryable=_is_retryable_exc) + result = _retry_transient(_do, is_retryable=_is_retryable_exc) + # One completed turn is one gen-ai trace. Recorded here, from the figure the turn already + # carries, so every caller is covered and no extra clock is read. A turn that raised is + # not recorded: it has no completed response to time. + note_trace_latency(result.turn_wall_clock_sec) + return result def _deadline(self, t0: float) -> tuple[float | None, float, str]: """The earlier of the turn and item caps, as (deadline, budget, scope). diff --git a/packages/gooddata-eval/src/gooddata_eval/core/interaction.py b/packages/gooddata-eval/src/gooddata_eval/core/interaction.py new file mode 100644 index 000000000..4d69f230c --- /dev/null +++ b/packages/gooddata-eval/src/gooddata_eval/core/interaction.py @@ -0,0 +1,57 @@ +# (C) 2026 GoodData Corporation +"""Whether an eval kind sends one chat message per run or drives a whole conversation. + +The distinction decides what a latency figure means. gen-ai starts one trace per chat message +(its session is the conversation), so a single-shot run is exactly one trace and its latency +is that trace's response time, while a multi-turn run spans several traces and has both a +per-trace and a whole-conversation latency. The report keeps the two apart for that reason. + +The classification is by kind, not by how many turns a particular run happened to take: an item +belongs to exactly one section however its runs went. +""" + +from __future__ import annotations + +from typing import Literal + +InteractionMode = Literal["single_shot", "multi_turn"] + +SINGLE_SHOT: InteractionMode = "single_shot" +MULTI_TURN: InteractionMode = "multi_turn" +INTERACTION_MODES: tuple[InteractionMode, ...] = (SINGLE_SHOT, MULTI_TURN) + +# Agentic kinds that still send exactly one chat message per run: no simulated user, no +# follow-up turns. Named explicitly, and checked against the runner's full list in the tests, +# so a new agentic kind cannot be classified by accident. +SINGLE_TURN_AGENTIC_KINDS = frozenset( + { + "agentic_general_question", + "agentic_guardrail", + "agentic_search", + } +) + +# Kinds that drive a simulated user or fixture turns until the agent has produced its output. +MULTI_TURN_KINDS = frozenset( + { + "vis_agentic", + "agentic_visualization", + "agentic_metric_skill", + "agentic_alert_skill", + "agentic_dashboard_skill", + "agentic_report_skill", + "agentic_conversation", + "agentic_kda_skill", + "agentic_anomaly_detection", + "agentic_what_if", + } +) + + +def interaction_mode(test_kind: str) -> InteractionMode: + """Single-shot unless the kind is one of the multi-turn ones. + + Every non-agentic kind sends one chat message (or one summary request), so the default is + the safe side for them. + """ + return MULTI_TURN if test_kind in MULTI_TURN_KINDS else SINGLE_SHOT diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py b/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py index 5d903b2d1..ebf9a09c9 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py @@ -5,11 +5,13 @@ from rich.table import Table from gooddata_eval.core.agentic._gate import gate_label +from gooddata_eval.core.interaction import MULTI_TURN, SINGLE_SHOT from gooddata_eval.core.runner import EvalReport, ItemReport from gooddata_eval.core.timing import LATENCY_PERCENTILES -# The side-by-side table compares the median and the tail; each model's own Latency line -# and the HTML report carry the full P50/P70/P90/P99 set. +# The side-by-side table compares the median and the tail of the main figure, single-shot +# per-trace latency; each model's own Latency lines and the HTML report carry the full +# P50/P70/P90/P99 set for every population. COMPARISON_PERCENTILES = (50, 90, 99) @@ -22,15 +24,42 @@ def _ungraded_note(item: ItemReport) -> str: return f"{criteria} criterion(s) ungraded" if criteria else "" -def _latency_line(stats: dict[str, float]) -> str: - """One line of per-run latency percentiles, with the sample size they are taken over. +_MODE_LABEL = {SINGLE_SHOT: "single-shot", MULTI_TURN: "multi-turn"} + + +def _latency_line(label: str, stats: dict[str, float]) -> str: + """One line of latency percentiles, naming the population and the sample size they are over. Percentiles only, no mean: the Summary line above already carries ``avg .../run``, and a - second average over a slightly different sample would put two near-identical numbers - side by side. + second average over a different sample would put two near-identical numbers side by side. """ percentiles = " · ".join(f"P{p} {stats[f'p{p}']:.2f}s" for p in LATENCY_PERCENTILES) - return f"Latency per run (n={stats['n']:.0f}): {percentiles}" + return f"Latency, {label} (n={stats['n']:.0f}): {percentiles}" + + +def _mode_lines(report: EvalReport) -> list[str]: + """Pass counts per mode, then each mode's latency distributions. + + A mode with no evaluated items is left out, and so is a distribution nothing was timed + for. Per trace is the main figure: a single-shot run is exactly one trace, so its + percentiles are the response time of one chat turn, while a multi-turn run spans several + traces and also gets a whole-conversation figure. + """ + by_mode = report.by_mode + present = [mode for mode in (SINGLE_SHOT, MULTI_TURN) if by_mode[mode]["total"]] + if not present: + return [] + lines = [ + "By mode: " + + " · ".join(f"{_MODE_LABEL[m]} {by_mode[m]['passed']}/{by_mode[m]['total']} passed" for m in present) + ] + for mode in present: + entry = by_mode[mode] + if entry["traces_s"]: + lines.append(_latency_line(f"{_MODE_LABEL[mode]} per trace", entry["traces_s"])) + if entry.get("runs_s"): + lines.append(_latency_line(f"{_MODE_LABEL[mode]} per full run", entry["runs_s"])) + return lines def render_console(report: EvalReport, *, console: Console | None = None) -> str: @@ -105,8 +134,8 @@ def render_console(report: EvalReport, *, console: Console | None = None) -> str f"({report.skipped} skipped, {report.errored} errored) " f"avg quality {report.avg_quality_score:.0%} in {timing}" ) - if (stats := report.latency_stats) is not None: - out.print(_latency_line(stats)) + for line in _mode_lines(report): + out.print(line) return out.export_text() if out.record else "" @@ -128,14 +157,14 @@ def render_comparison(reports: list[EvalReport], *, console: Console | None = No table.add_column("Quality") table.add_column("Avg/run") for p in COMPARISON_PERCENTILES: - table.add_column(f"P{p}") + table.add_column(f"Trace P{p}") table.add_column("Total time") for r in reports: evaluated = r.total - r.skipped pass_pct = f"{r.passed / evaluated:.0%}" if evaluated else "—" model_label = f"{r.provider_name}/{r.model}" if r.provider_name else r.model or "?" - stats = r.latency_stats + stats = r.by_mode[SINGLE_SHOT]["traces_s"] table.add_row( model_label, f"{r.passed}/{r.total} ({pass_pct})", @@ -146,6 +175,7 @@ def render_comparison(reports: list[EvalReport], *, console: Console | None = No ) out.print(table) + out.print("Trace P50/P90/P99: single-shot chat-turn response time, one trace per run.") evaluated_reports = [r for r in reports if r.total > 0] if evaluated_reports: diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py b/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py index 88776773a..f52eeaa02 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py @@ -29,15 +29,18 @@ def _build_run_dict(report: EvalReport) -> dict: "latency_s": round(report.latency_s, 3), "avg_latency_s": round(report.avg_latency_s, 3), "wall_clock_s": round(report.wall_clock_s, 3), - # The distribution of every timed run -- avg_latency_s above is one number for - # the same population and says nothing about its tail. None when no run was - # timed, so a report cannot state a 0s P99 for a run that measured nothing. - "latency_stats_s": report.latency_stats, + # Pass counts and latency distributions split by single-shot and multi-turn: + # avg_latency_s above is one number over a mixed population and says nothing about + # its tail. Each distribution is None when nothing was timed, so a report cannot + # state a 0s P99 for a run that measured nothing. + "by_mode": report.by_mode, }, "items": { item.id: { "dataset_name": item.dataset_name, "test_kind": item.test_kind, + # Which section of the report the item belongs to, by its kind. + "mode": item.interaction_mode, "question": item.question, # Both, because under --gate power they differ: pass_at_k stays literal # (any run passed) and matches the Langfuse score of that name, while @@ -51,9 +54,12 @@ def _build_run_dict(report: EvalReport) -> dict: "runs": item.runs_total, "latency_s": round(item.latency_s, 3), "avg_latency_s": round(item.avg_latency_s, 3), - # One entry per run that completed, passing or not: the sample the run-level - # percentiles are taken over. latency_s is only their sum. + # One entry per run that completed, passing or not: the sample behind the + # multi-turn per-run percentiles. latency_s is only their sum. "run_latencies_s": [round(latency, 3) for latency in item.run_latencies_s], + # One entry per completed chat turn (one gen-ai trace each): the sample behind + # the per-trace percentiles, and for a single-shot item one per run. + "trace_latencies_s": [round(latency, 3) for latency in item.trace_latencies_s], # Beside `runs`, not folded into it: "4 of 5 passed" is a different fact # from pass_at_k and the only one that separates a reliable item from a # coin-flip. pass_power_k is the unanimity flag beside it, and @@ -100,7 +106,7 @@ def _build_comparison_entry(report: EvalReport) -> dict: "avg_quality_score": round(report.avg_quality_score, 4), "avg_latency_s": round(report.avg_latency_s, 3), "total_latency_s": round(report.latency_s, 3), - "latency_stats_s": report.latency_stats, + "by_mode": report.by_mode, } diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index 215c26e8d..c9b896b5e 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -83,23 +83,18 @@ .card > b { display: block; font-size: 13px; font-weight: 700; color: var(--ink-strong); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .card .big { font-size: 26px; font-weight: 700; color: var(--ink-strong); font-variant-numeric: tabular-nums; margin: 2px 0; } .card .sub2, .sub2 { font-size: 12px; color: var(--dim); } - .lat { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); } - /* The four percentiles, with a lime rule: they are what the card is read for. */ - .pcts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; border-top: 3px solid var(--lime); background: var(--bg); border-radius: 0 0 3px 3px; } - .pct { padding: 6px 8px 7px; min-width: 0; } - .pct + .pct { border-left: 1px solid var(--line); } - .pct .k { display: block; font-size: 11px; font-weight: 700; color: var(--dim); letter-spacing: .04em; } - .pct .v { display: block; font-size: 16px; font-weight: 700; color: var(--ink-strong); font-variant-numeric: tabular-nums; white-space: nowrap; } - .pct .s { display: block; font-size: 10px; color: var(--dim); min-height: 13px; } - .lat .sub2 { margin-top: 6px; } - /* min..max track; the band is P50..P90 and the ticks sit at P50/P70/P90/P99. Positions are - taken from the figures in the report, nothing is computed here beyond the scale. */ - .strip { position: relative; height: 18px; margin-top: 8px; } + /* Latency tables: one row per run, one table per population. The strip marks where the + percentiles fall between the fastest and slowest sample (band = P50..P90); positions come + from the figures in the report, nothing is computed here beyond the scale. */ + .lat-table .strip { position: relative; width: 150px; height: 18px; } .strip::before { content: ""; position: absolute; left: 0; right: 0; top: 7px; height: 4px; background: var(--track); border-radius: 2px; } .strip .band { position: absolute; top: 7px; height: 4px; background: var(--primary); opacity: .45; border-radius: 2px; } .strip .tick { position: absolute; top: 3px; width: 2px; height: 12px; margin-left: -1px; background: var(--dim); border-radius: 1px; } .strip .tick.t50 { top: 0; height: 18px; background: var(--primary-ink); } - .strip-lbl { display: flex; justify-content: space-between; font-size: 11px; color: var(--dim); font-variant-numeric: tabular-nums; } + .blurb { font-size: 12px; color: var(--dim); margin: 0 0 10px; max-width: 100ch; } + .tablewrap.flat { max-height: none; margin-bottom: 4px; } + h3.cap { margin: 14px 0 6px; } + #latency h2:not(:first-child) { margin-top: 26px; } .note { font-size: 12px; color: var(--dim); margin-top: 8px; } #filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; } input, select, button { @@ -153,11 +148,17 @@

+

Items

+ - +
+ any kind + +
${esc(k)}${kindCount[k]}`).join(''); +const chosenKinds = () => [...document.querySelectorAll('#kindmenu input:checked')].map((i) => i.value); +function kindSummary() { + const c = chosenKinds(); + $('#kind > summary').textContent = c.length === 0 ? 'any kind' : c.length === 1 ? c[0] : `${c.length} kinds`; +} +$('#kind').addEventListener('input', kindSummary); +$('#kindclear').addEventListener('click', () => { + document.querySelectorAll('#kindmenu input').forEach((i) => { i.checked = false; }); + kindSummary(); + render(); +}); +document.addEventListener('click', (e) => { if (!$('#kind').contains(e.target)) $('#kind').open = false; }); +$('#kind').addEventListener('keydown', (e) => { + if (e.key === 'Escape' && $('#kind').open) { $('#kind').open = false; $('#kind > summary').focus(); } +}); $('#focus').innerHTML = LABELS.map((l) => ``).join(''); let focus = LABELS[0]; let sortKey = 'id', sortDir = 1, selected = null; @@ -343,11 +417,11 @@

Items

function visible() { const text = $('#q').value.trim().toLowerCase(); - const kind = $('#kind').value, mode = $('#mode').value, status = $('#status').value, fn = exprFn(); + const picked = new Set(chosenKinds()), mode = $('#mode').value, status = $('#status').value, fn = exprFn(); let bad = false; const out = rows.filter((r) => { const it = r.per[focus]; - if (kind && r.kind !== kind) return false; + if (picked.size && !picked.has(r.kind)) return false; if (mode && r.mode !== mode) return false; if (status !== 'all' && statusOf(it) !== status) return false; if (text && !(r.id + ' ' + r.question).toLowerCase().includes(text)) return false; @@ -367,7 +441,8 @@

Items

} function sortVal(r, key) { if (key === 'id' || key === 'kind' || key === 'mode' || key === 'question') return r[key] || ''; - if (key === 'latency') return (r.per[focus] || {}).avg_latency_s ?? -1; + if (key === 'latency_trace') return traceLatency(r.per[focus]) ?? -1; + if (key === 'latency_run') return runLatency(r.per[focus]) ?? -1; return LBL[statusOf(r.per[key])] || ''; // a run column } @@ -376,9 +451,9 @@

Items

$('#count').textContent = `${list.length} of ${rows.length}`; const heads = [['id', 'Item'], ['kind', 'Kind'], ['mode', 'Mode'], ['question', 'Question']] .concat(LABELS.map((l) => [l, l])) - .concat([['latency', 'Latency']]); - $('#items thead').innerHTML = '' + heads.map(([k, h]) => - `${esc(h)}${sortKey === k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; + .concat([['latency_trace', 'Latency / trace', TRACE_HELP], ['latency_run', 'Latency / run', RUN_HELP]]); + $('#items thead').innerHTML = '' + heads.map(([k, h, help]) => + `${esc(h)}${sortKey === k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; $('#items tbody').innerHTML = list.map((r) => { const it = r.per[focus] || {}; return ` @@ -387,7 +462,8 @@

Items

${r.mode ? `${esc(MODE_NAME[r.mode] || r.mode)}` : ''} ${esc(r.question)}` + LABELS.map((l) => `${chipHtml(statusOf(r.per[l]))}`).join('') + - `${num(it.avg_latency_s)}s`; + latencyCell(traceLatency(it), samplesTitle(it.trace_latencies_s, 'trace')) + + latencyCell(runLatency(it), samplesTitle(it.run_latencies_s, 'run')) + ''; }).join(''); $('#noitems').hidden = list.length > 0; } @@ -424,6 +500,25 @@

Items

}); // ---------- drawer ---------- +// Two different numbers, so each says what it is: the trace chip is the item's chat turns (the same figure as +// the table's Latency / trace); the run chip is the one run whose detail and timeline the drawer shows, which +// also contains the judge and, for multi-turn, the simulated user. +function drawerLatencyChips(it) { + const chip = (text, title) => `${text}`; + const traces = it.trace_latencies_s || []; + const out = []; + if (traces.length) { + out.push(chip(`${traces.length} trace${traces.length === 1 ? '' : 's'} · median ${secs(median(traces))}`, + `Response time of each chat turn. ${samplesTitle(traces, 'trace')}`)); + } + if (typeof it.best_run_latency_s === 'number') { + out.push(chip(`run ${secs(it.best_run_latency_s)}`, + 'Wall-clock of the run described below, including the judge and, for multi-turn, the simulated user.')); + } else if (it.runs && typeof it.avg_latency_s === 'number') { + out.push(chip(`avg run ${secs(it.avg_latency_s)}`, 'Mean wall-clock of this item\'s runs, including the judge.')); + } + return out.join(''); +} function openDrawer(r) { if (!r) return; const it = r.per[focus]; @@ -438,7 +533,7 @@

${esc(focus)}

${chipHtml(st)} ${esc(r.kind)} ${it.runs ?? 0} run(s) - ${num(it.best_run_latency_s ?? it.avg_latency_s)}s + ${drawerLatencyChips(it)} ${it.classification ? `${esc(it.classification)}` : ''}
${it.error ? `

Error

${esc(it.error)}
` : ''} diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index ed13b0a83..c6bb38eb3 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -252,6 +252,33 @@ def test_the_page_has_single_shot_and_multi_turn_sections_read_from_the_report() assert "sort(" not in section +def test_the_item_table_names_what_each_latency_column_measures(): + """One unlabelled "Latency" column mixed a whole-run average with the per-trace figures above it. The + table now carries both populations under their own names, read from the item's recorded samples.""" + html = build_html(_doc_with_modes("gpt-5")) + + for text in ( + "Latency / trace", + "Latency / run", + "trace_latencies_s", + "run_latencies_s", + "latency_trace", + "latency_run", + ): + assert text in html + assert "['latency', 'Latency']" not in html + + +def test_the_kind_filter_is_a_multi_select(): + html = build_html(_doc_with_modes("gpt-5")) + + assert '
Date: Sun, 11 Oct 2026 23:30:34 +0200 Subject: [PATCH 5/9] feat(gooddata-eval): filters on top drive the whole HTML report The filters sat above the item table and only narrowed that table, so the run cards, the comparison and the latency tables kept describing the full set while the rows underneath described a few. Move the filter bar to the top of the page, sticky on wide windows, and make it select one set of items that every section covers. Kind, mode and search are properties of the item; status and the expression are read from the run picked in the run selector, which is hidden for a single run. A filtered view states how many items it covers, and Reset clears it. Unfiltered, the page still reads summary, comparison and by_mode from the report, so it cannot disagree with the console or the JSON. Only a narrowed selection is recomputed in the page, from the selected items' own records, by the rules EvalReport uses. An empty selection shows dashes rather than a 0% rate. Status chips and the recomputed counts follow gate_passed instead of pass_at_k, so they agree with the report's own counts under --gate power. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 17 +- .../core/reporting/report_template.html | 228 +++++++++++++----- .../gooddata-eval/tests/test_html_report.py | 37 ++- 3 files changed, 214 insertions(+), 68 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index a4cba0e46..a122e3afb 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -382,8 +382,15 @@ gd-eval report results.json -o customer.html --redact | `--title TEXT` | Title shown in the report header. | | `--redact` | Drop conversation/response ids and raw reasoning, and rename models to `Model A`, `Model B`, … Pass rate, per-item results, questions and latency survive. | -The report is a *view* over the JSON — it computes no population statistics of its own (the only arithmetic is -an item's median and mean over its own recorded samples). It gives you: +The report is a *view* over the JSON. Unfiltered, every figure on it is read from the report, so it cannot +disagree with the console or the JSON. Narrow the selection with a filter and the figures are recomputed in the +page from the selected items' own records, by the same rules gd-eval uses. It gives you: + +- **Filters at the top, for the whole page** — search, kind (several at once), mode, status and an expression + sit in a bar above everything and stay in view. They select a set of items, and the run cards, the comparison, + the latency tables and the item table all cover exactly that set, so the runs stay comparable. Status and the + expression are read from the run chosen in the run selector (shown when there is more than one run), and the + page says so. A filtered view states how many items it covers, and Reset clears it. - **Run cards and a comparison table** — pass rate and quality per run, with the pass counts split by single-shot and multi-turn and the single-shot per-trace median and tail on each card. @@ -392,14 +399,14 @@ an item's median and mean over its own recorded samples). It gives you: the average, the maximum, the number of samples and a strip marking where the percentiles fall between the fastest and slowest sample. They are read from `by_mode`, never recomputed in the page. - **An item table** with a pass/fail column per run, so a model or run-over-run - regression is one glance rather than a hand-assembled spreadsheet. A mode column and filter separate - single-shot from multi-turn items, and the kind filter takes several kinds at once. Two latency columns + regression is one glance rather than a hand-assembled spreadsheet. A mode column separates + single-shot from multi-turn items. Two latency columns keep the populations apart: **Latency / trace** is the median response time of the item's chat turns, and **Latency / run** is the mean wall-clock of a whole run (the chat turns plus the judge and, for multi-turn, the simulated user). Hover a value to see every sample behind it; the drawer labels its two latency chips the same way. A report written before per-trace timing has no trace figure and shows `avg_latency_s` as the run figure. -- **An expression filter** for cross-cutting questions the fixed filters can't +- **An expression filter** (in the bar above) for cross-cutting questions the fixed filters can't anticipate, e.g. `d.filter_ranking_score === false` or `d.expected_metric_uris.length > 1 && !d.metrics_correct`. Available variables: `d` (the focused run's `detail`), `it` (its item), `i` (the row, `i.per[label]` for any diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index c2988d1f6..c6fbec456 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -96,7 +96,18 @@ h3.cap { margin: 14px 0 6px; } #latency h2:not(:first-child) { margin-top: 26px; } .note { font-size: 12px; color: var(--dim); margin-top: 8px; } - #filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; } + /* The filters sit above the whole dashboard and stay in view: every table below follows them. */ + #filterbar { + position: sticky; top: 0; z-index: 10; margin: -18px -22px 12px; padding: 10px 22px; + background: var(--bg); border-bottom: 1px solid var(--line); + } + #filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + #reset { cursor: pointer; color: var(--primary-ink); } + .scope { + margin: 0 0 14px; padding: 6px 10px; font-size: 12px; background: var(--primary-tint); + border-left: 3px solid var(--primary); border-radius: 0 3px 3px 0; + } + @media (max-width: 900px) { #filterbar { position: static; } } input, select, button { font: inherit; padding: 5px 9px; border: 1px solid var(--line); border-radius: 3px; background: var(--panel); color: inherit; @@ -151,7 +162,7 @@ .turn.note { border-left-color: var(--warn); background: var(--warn-bg); font-style: italic; } .turn .who { font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); font-weight: 700; } .turn .said { font-size: 12.5px; white-space: pre-wrap; overflow-wrap: anywhere; } - .chip { display: inline-block; padding: 0 6px; border-radius: 3px; font-size: 11px; background: var(--track); color: var(--ink); margin-right: 4px; } + .chip { display: inline-block; white-space: nowrap; padding: 0 6px; border-radius: 3px; font-size: 11px; background: var(--track); color: var(--ink); margin-right: 4px; } .chip.y { background: var(--pass-bg); color: var(--pass-ink); } .chip.n { background: var(--fail-bg); color: var(--fail-ink); } .close { float: right; cursor: pointer; color: var(--dim); background: none; border: 0; font-size: 18px; line-height: 1; } .empty { color: var(--dim); padding: 20px 0; } @@ -170,11 +181,7 @@

-
-
-
-
-

Items

+
@@ -193,13 +200,21 @@

Items

- + +
-
- expression vars: d detail of focused run · it focused run's item · i row (i.per[label]) · q question · kind -
+
+
+ expression vars: d detail of focused run · it focused run's item · i row (i.per[label]) · q question · kind +
+ +
+
+
+
+

Items

@@ -239,7 +254,9 @@

Items

if (!r.mode) r.mode = item.mode || ''; } } -const statusOf = (item) => !item ? 'none' : item.skipped ? 'skip' : item.error ? 'err' : item.pass_at_k ? 'pass' : 'fail'; +// The verdict an item was decided on: gate_passed (pass^K under --gate power), else pass_at_k for older reports. +const passedOf = (item) => !!(item.gate_passed ?? item.pass_at_k); +const statusOf = (item) => !item ? 'none' : item.skipped ? 'skip' : item.error ? 'err' : passedOf(item) ? 'pass' : 'fail'; const LBL = { pass: 'PASS', fail: 'FAIL', err: 'ERR', skip: 'SKIP', none: '–' }; const chipHtml = (s) => `${LBL[s]}`; @@ -249,42 +266,98 @@

Items

$('#meta').innerHTML = `${rows.length} item(s) · ${LABELS.length} run(s) · generated ${esc(DATA.generated_at)}` + (DATA.redacted ? 'REDACTED' : ''); -// ---------- mode split: read from the report, never recomputed here ---------- -// gd-eval writes the pass counts and latency distributions per mode when it writes the JSON, so this page -// cannot disagree with the console or the JSON. A report from an older gd-eval has none and says so. +// ---------- the figures behind the dashboard ---------- +// Unfiltered, every figure is read from the report (summary, comparison and by_mode), so this page cannot +// disagree with the console or the JSON. Once a filter narrows the selection those figures no longer describe it, +// so they are recomputed from the selected items' own records by the rules EvalReport uses (core/runner.py). +// A report from an older gd-eval has no by_mode and says so. const MODES = [['single_shot', 'Single-shot'], ['multi_turn', 'Multi-turn']]; const MODE_NAME = { single_shot: 'single-shot', multi_turn: 'multi-turn' }; -function modeOf(label, mode) { - const c = DATA.comparison[label] || {}; - const s = DATA.runs[label].summary || {}; - return ((c.by_mode || s.by_mode || {})[mode]) || null; +const figures = {}; + +function reported(label) { + const c = DATA.comparison[label] || {}, s = DATA.runs[label].summary || {}; + const total = c.total ?? s.total ?? 0, passed = c.passed ?? s.passed ?? 0; + return { + total, passed, errored: s.errored ?? 0, skipped: s.skipped ?? 0, + pass_rate: c.pass_rate ?? (total ? passed / total : 0), + avg_quality_score: c.avg_quality_score, + avg_latency_s: c.avg_latency_s ?? s.avg_latency_s, + total_latency_s: c.total_latency_s ?? s.latency_s, + by_mode: c.by_mode || s.by_mode || null, + }; +} +const r3 = (v) => Math.round(v * 1000) / 1000; +// Linear interpolation between closest ranks, as gd-eval computes it, so P50 is the median. +function percentile(sorted, p) { + const rank = (sorted.length - 1) * p / 100, lower = Math.floor(rank), upper = Math.min(lower + 1, sorted.length - 1); + return sorted[lower] + (sorted[upper] - sorted[lower]) * (rank - lower); } -const hasModes = LABELS.some((l) => (DATA.comparison[l] || {}).by_mode || (DATA.runs[l].summary || {}).by_mode); +function latencyStats(samples) { + const s = [...samples].sort((a, b) => a - b); + if (!s.length) return null; + return { n: s.length, mean: r3(mean(s)), min: r3(s[0]), p50: r3(percentile(s, 50)), p70: r3(percentile(s, 70)), + p90: r3(percentile(s, 90)), p99: r3(percentile(s, 99)), max: r3(s[s.length - 1]) }; +} +// Fraction of the best run's boolean checks that held; with none, 1 or 0 by the verdict. +function qualityOf(it) { + const checks = Object.values(it.detail || {}).filter((v) => typeof v === 'boolean'); + return checks.length ? checks.filter(Boolean).length / checks.length : (passedOf(it) ? 1 : 0); +} +function recompute(label, ids) { + const items = ids.map((id) => DATA.runs[label].items[id]).filter(Boolean); + const evaluated = items.filter((it) => !it.skipped && it.error == null); + const passed = items.filter(passedOf).length; + const latency = items.reduce((a, it) => a + (it.latency_s || 0), 0); + const runs = items.reduce((a, it) => a + (it.runs || 0), 0); + const by_mode = {}; + for (const [m] of MODES) { + const inMode = items.filter((it) => it.mode === m && !it.skipped); + by_mode[m] = { + total: inMode.length, passed: inMode.filter(passedOf).length, + traces_s: latencyStats(inMode.flatMap((it) => it.trace_latencies_s || [])), + }; + if (m === 'multi_turn') by_mode[m].runs_s = latencyStats(inMode.flatMap((it) => it.run_latencies_s || [])); + } + return { + total: items.length, passed, errored: items.filter((it) => it.error != null).length, + skipped: items.filter((it) => it.skipped).length, pass_rate: items.length ? passed / items.length : 0, + avg_quality_score: evaluated.length ? mean(evaluated.map(qualityOf)) : 0, + avg_latency_s: runs ? latency / runs : 0, total_latency_s: latency, by_mode, + }; +} +function modeOf(label, mode) { return ((figures[label] || {}).by_mode || {})[mode] || null; } +const hasModes = LABELS.some((l) => reported(l).by_mode); // ---------- summary cards ---------- -$('#summary').innerHTML = '

Runs

' + LABELS.map((label) => { - const s = DATA.runs[label].summary || {}; - const rate = s.total ? s.passed / s.total : 0; - const split = MODES.map(([m]) => [m, modeOf(label, m)]).filter(([, x]) => x && x.total) - .map(([m, x]) => `${MODE_NAME[m]} ${x.passed}/${x.total}`); - const trace = (modeOf(label, 'single_shot') || {}).traces_s; - return `
${esc(label)} -
${pct(rate)}
-
${s.passed ?? 0}/${s.total ?? 0} passed · ${s.errored ?? 0} err · ${s.skipped ?? 0} skip
- ${split.length ? `
${split.join(' · ')} passed
` : ''} -
- ${trace ? `
single-shot per trace · P50 ${secs(trace.p50)} · P90 ${secs(trace.p90)} · P99 ${secs(trace.p99)}
` : ''}
`; -}).join('') + '
'; +function renderSummary() { + $('#summary').innerHTML = '

Runs

' + LABELS.map((label) => { + const f = figures[label]; + const rate = f.total ? f.passed / f.total : 0; + const split = MODES.map(([m]) => [m, modeOf(label, m)]).filter(([, x]) => x && x.total) + .map(([m, x]) => `${MODE_NAME[m]} ${x.passed}/${x.total}`); + const trace = (modeOf(label, 'single_shot') || {}).traces_s; + return `
${esc(label)} +
${f.total ? pct(rate) : '–'}
+
${f.passed}/${f.total} passed · ${f.errored} err · ${f.skipped} skip
+ ${split.length ? `
${split.join(' · ')} passed
` : ''} +
+ ${trace ? `
single-shot per trace · P50 ${secs(trace.p50)} · P90 ${secs(trace.p90)} · P99 ${secs(trace.p99)}
` : ''}
`; + }).join('') + '
'; +} // ---------- comparison ---------- -if (LABELS.length > 1) { +function renderComparison() { + if (LABELS.length < 2) { $('#comparison').innerHTML = ''; return; } + // A selection with no items has no rate, quality or latency, rather than a 0% one. + const some = (l, text) => (figures[l].total ? text : '–'); const cols = [ ['Run', (l) => esc(l), ''], - ['Pass rate', (l) => pct(cmp(l).pass_rate), 'num'], - ['Passed', (l) => `${cmp(l).passed ?? 0}/${cmp(l).total ?? 0}`, 'num'], - ['Avg quality', (l) => pct(cmp(l).avg_quality_score), 'num'], - ['Avg latency / run', (l) => num(cmp(l).avg_latency_s) + 's', 'num'], - ['Total latency', (l) => num(cmp(l).total_latency_s, 1) + 's', 'num'], + ['Pass rate', (l) => some(l, pct(figures[l].pass_rate)), 'num'], + ['Passed', (l) => `${figures[l].passed}/${figures[l].total}`, 'num'], + ['Avg quality', (l) => some(l, pct(figures[l].avg_quality_score)), 'num'], + ['Avg latency / run', (l) => some(l, num(figures[l].avg_latency_s) + 's'), 'num'], + ['Total latency', (l) => some(l, num(figures[l].total_latency_s, 1) + 's'), 'num'], ]; // Wrapped like the item table: with several runs it is wider than a narrow window, and it // must scroll inside its own box rather than clip. @@ -335,20 +408,16 @@

Items

return `

${title}

${blurb}

` + tables.map(([caption, spec]) => (caption ? `

${caption}

` : '') + latencyTable({ mode, ...spec })).join(''); } -$('#latency').innerHTML = !hasModes - ? '

Latency

This report was written before gd-eval split latency into single-shot and multi-turn. Re-run to get per-trace percentiles.

' - : modeSection('single_shot', 'Single-shot · latency per trace', - 'One chat turn per run, so each run is exactly one trace. Latency is that trace\'s response time, from sending the message to the end of its stream; the judge, the simulated user and conversation set-up are not in it.', - [['', { field: 'traces_s', sample: 'Traces', counts: true }]]) + - modeSection('multi_turn', 'Multi-turn · conversations', - 'A conversation spans several traces, so it is shown both ways: per trace (each chat turn, comparable with single-shot) and per full run (the whole conversation, with simulated-user replies and the judge).', - [['Per trace · each chat turn', { field: 'traces_s', sample: 'Traces', counts: true }], - ['Per full run · whole conversation', { field: 'runs_s', sample: 'Runs', counts: false }]]); -function cmp(label) { - const c = DATA.comparison[label]; - if (c) return c; - const s = DATA.runs[label].summary || {}; - return { passed: s.passed, total: s.total, pass_rate: s.total ? s.passed / s.total : 0, avg_latency_s: s.avg_latency_s, total_latency_s: s.latency_s }; +function renderLatency() { + $('#latency').innerHTML = !hasModes + ? '

Latency

This report was written before gd-eval split latency into single-shot and multi-turn. Re-run to get per-trace percentiles.

' + : modeSection('single_shot', 'Single-shot · latency per trace', + 'One chat turn per run, so each run is exactly one trace. Latency is that trace\'s response time, from sending the message to the end of its stream; the judge, the simulated user and conversation set-up are not in it.', + [['', { field: 'traces_s', sample: 'Traces', counts: true }]]) + + modeSection('multi_turn', 'Multi-turn · conversations', + 'A conversation spans several traces, so it is shown both ways: per trace (each chat turn, comparable with single-shot) and per full run (the whole conversation, with simulated-user replies and the judge).', + [['Per trace · each chat turn', { field: 'traces_s', sample: 'Traces', counts: true }], + ['Per full run · whole conversation', { field: 'runs_s', sample: 'Runs', counts: false }]]); } // ---------- per-item latency ---------- @@ -396,7 +465,15 @@

Items

$('#kind').addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('#kind').open) { $('#kind').open = false; $('#kind > summary').focus(); } }); +$('#reset').addEventListener('click', () => { + $('#q').value = ''; $('#expr').value = ''; $('#mode').value = ''; $('#status').value = 'all'; + document.querySelectorAll('#kindmenu input').forEach((i) => { i.checked = false; }); + kindSummary(); + render(); +}); $('#focus').innerHTML = LABELS.map((l) => ``).join(''); +// With one run there is nothing to choose between. +$('#focus').hidden = LABELS.length < 2; let focus = LABELS[0]; let sortKey = 'id', sortDir = 1, selected = null; @@ -415,7 +492,9 @@

Items

} } -function visible() { +// The rows every table on the page covers. Kind, mode and search are properties of the item; status and the +// expression are read from the focused run. One selection for all runs keeps their columns comparable. +function matching() { const text = $('#q').value.trim().toLowerCase(); const picked = new Set(chosenKinds()), mode = $('#mode').value, status = $('#status').value, fn = exprFn(); let bad = false; @@ -432,12 +511,14 @@

Items

return true; }); if (bad) $('#expr').classList.add('bad'); + return out; +} +function sortRows(list) { const key = sortKey; - out.sort((a, b) => { + return [...list].sort((a, b) => { const va = sortVal(a, key), vb = sortVal(b, key); return (va < vb ? -1 : va > vb ? 1 : 0) * sortDir; }); - return out; } function sortVal(r, key) { if (key === 'id' || key === 'kind' || key === 'mode' || key === 'question') return r[key] || ''; @@ -446,9 +527,34 @@

Items

return LBL[statusOf(r.per[key])] || ''; // a run column } +let shown = []; +const filtersActive = () => !!($('#q').value.trim() || $('#expr').value.trim() || chosenKinds().length || + $('#mode').value || $('#status').value !== 'all'); +function renderScope() { + const active = filtersActive(); + $('#count').textContent = `${shown.length} of ${rows.length} items`; + $('#reset').hidden = !active; + const readsFocus = LABELS.length > 1 && ($('#status').value !== 'all' || $('#expr').value.trim()); + $('#scope').hidden = !active; + $('#scope').innerHTML = active + ? `Filtered: every table on this page covers these ${shown.length} of ${rows.length} items.` + + (readsFocus ? ` Status and expression are read from ${esc(focus)}.` : '') + : ''; +} +// Everything on the page follows the filters. Sorting and selecting a row only touch the item table. function render() { - const list = visible(); - $('#count').textContent = `${list.length} of ${rows.length}`; + shown = matching(); + const filtered = shown.length !== rows.length; + const ids = shown.map((r) => r.id); + for (const l of LABELS) figures[l] = filtered ? recompute(l, ids) : reported(l); + renderScope(); + renderSummary(); + renderComparison(); + renderLatency(); + renderItems(); +} +function renderItems() { + const list = sortRows(shown); const heads = [['id', 'Item'], ['kind', 'Kind'], ['mode', 'Mode'], ['question', 'Question']] .concat(LABELS.map((l) => [l, l])) .concat([['latency_trace', 'Latency / trace', TRACE_HELP], ['latency_run', 'Latency / run', RUN_HELP]]); @@ -471,10 +577,10 @@

Items

function sortBy(k) { sortDir = sortKey === k ? -sortDir : 1; sortKey = k; - render(); + renderItems(); } function openRow(id) { - selected = id; render(); openDrawer(byId.get(id)); + selected = id; renderItems(); openDrawer(byId.get(id)); } $('#items thead').addEventListener('click', (e) => { const k = e.target.closest('th')?.dataset.k; @@ -542,7 +648,7 @@

${esc(focus)}

${timelineHtml(it)} ${reasoningHtml(it)} ${idsHtml(it)}`; - d.querySelector('.close').onclick = () => { d.classList.remove('open'); selected = null; render(); }; + d.querySelector('.close').onclick = () => { d.classList.remove('open'); selected = null; renderItems(); }; d.querySelectorAll('.tl .row').forEach((el) => { el.onclick = () => { const box = $('#tl-detail'); diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index c6bb38eb3..81dca672e 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -248,8 +248,10 @@ def test_the_page_has_single_shot_and_multi_turn_sections_read_from_the_report() assert text in html for label in ("P50", "P70", "P90", "P99"): assert label in html - section = html.split("// ---------- latency by mode")[1].split("function cmp(label)")[0] - assert "sort(" not in section + # The table renderers only lay out figures they are handed; the one place that computes any is `recompute`. + renderers = html.split("// ---------- latency by mode")[1].split("function renderLatency")[0] + assert "sort(" not in renderers + assert "percentile(" not in renderers def test_the_item_table_names_what_each_latency_column_measures(): @@ -269,6 +271,37 @@ def test_the_item_table_names_what_each_latency_column_measures(): assert "['latency', 'Latency']" not in html +def test_the_filters_sit_above_the_dashboard_and_drive_every_table(): + html = build_html(_doc_with_modes("gpt-5")) + + bar = html.index('id="filterbar"') + for section in ('id="summary"', 'id="comparison"', 'id="latency"', 'id="items"'): + assert bar < html.index(section), f"the filter bar must come before {section}" + # One render path feeds the cards, the comparison, the latency tables and the item table. + render = html.split("function render() {")[1].split("function renderItems")[0] + for call in ("renderSummary()", "renderComparison()", "renderLatency()", "renderItems()"): + assert call in render + + +def test_a_filtered_view_recomputes_and_an_unfiltered_one_reads_the_report(): + """Unfiltered, the page shows the report's own figures so it cannot disagree with the console or the + JSON; only a narrowed selection is recomputed, from the selected items' records.""" + html = build_html(_doc_with_modes("gpt-5")) + + assert "filtered ? recompute(l, ids) : reported(l)" in html + assert "function recompute(label, ids)" in html + assert "function reported(label)" in html + + +def test_an_item_is_judged_by_the_gate_verdict_not_pass_at_k_alone(): + """Under --gate power an item that passed some runs has pass_at_k True and gate_passed False; the + report's counts follow the gate, so the page's status chips and recomputed counts must too.""" + html = build_html(_doc_with_modes("gpt-5")) + + assert "item.gate_passed ?? item.pass_at_k" in html + assert "item.pass_at_k ? 'pass'" not in html + + def test_the_kind_filter_is_a_multi_select(): html = build_html(_doc_with_modes("gpt-5")) From 515c384b61faf6e91d47d22e125e2020fc559259 Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Sun, 11 Oct 2026 23:39:48 +0200 Subject: [PATCH 6/9] feat(gooddata-eval): two filter tiers, multi-select status and run The single filter bar drove the whole page, so there was no way to look at a few rows of the table without also changing every figure above it, and status and the run picker could only hold one value. Keep the bar at the top for the whole page (kind, mode, status, run) and repeat those filters in the Items section, where they narrow the table only. Search and the expression now live only in the Items section. The table starts from the rows the top filters allow and only narrows them; its menus offer just what survives the top selection, and a ticked value that no longer survives is dropped. Status and run are multi-selects, like kind. The run filter chooses which runs the page (or the table) shows, replacing the single focused run; the item table gains a status and latency column per run shown, and the drawer gets a run switcher. With several runs an item matches a status, or an expression, when any of the runs in play does. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 19 +- .../core/reporting/report_template.html | 379 ++++++++++++------ .../gooddata-eval/tests/test_html_report.py | 40 +- 3 files changed, 300 insertions(+), 138 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index a122e3afb..d9cc1d5aa 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -386,12 +386,15 @@ The report is a *view* over the JSON. Unfiltered, every figure on it is read fro disagree with the console or the JSON. Narrow the selection with a filter and the figures are recomputed in the page from the selected items' own records, by the same rules gd-eval uses. It gives you: -- **Filters at the top, for the whole page** — search, kind (several at once), mode, status and an expression - sit in a bar above everything and stay in view. They select a set of items, and the run cards, the comparison, - the latency tables and the item table all cover exactly that set, so the runs stay comparable. Status and the - expression are read from the run chosen in the run selector (shown when there is more than one run), and the - page says so. A filtered view states how many items it covers, and Reset clears it. - +- **Filters in two tiers** — the bar at the top (kind, mode, status and run) decides what the whole page covers: + the run cards, the comparison, the latency tables and the item table all follow it, so the runs stay + comparable. The Items section repeats kind, mode, status and run and adds the search box and the expression; + those narrow the table only, inside whatever the top bar allows, and only offer what the top bar leaves (a + kind the top filters removed disappears from the table's menu and its selection). Kind, status and run are + multi-selects; nothing ticked means all. The run filter chooses which runs are shown; a status matches an item + when any of the runs in play has it (so "failed or errored" over previous and current finds every item that + broke in either), and the expression is evaluated once per run in play with the same any-run rule. A filtered + page states how many items it covers, and each tier has its own Reset. - **Run cards and a comparison table** — pass rate and quality per run, with the pass counts split by single-shot and multi-turn and the single-shot per-trace median and tail on each card. - **Latency sections** — **Single-shot · latency per trace** first, as the main figure, then **Multi-turn**, which @@ -406,10 +409,10 @@ page from the selected items' own records, by the same rules gd-eval uses. It gi the simulated user). Hover a value to see every sample behind it; the drawer labels its two latency chips the same way. A report written before per-trace timing has no trace figure and shows `avg_latency_s` as the run figure. -- **An expression filter** (in the bar above) for cross-cutting questions the fixed filters can't +- **An expression filter** (in the Items section) for cross-cutting questions the fixed filters can't anticipate, e.g. `d.filter_ranking_score === false` or `d.expected_metric_uris.length > 1 && !d.metrics_correct`. Available variables: - `d` (the focused run's `detail`), `it` (its item), `i` (the row, `i.per[label]` for any + `d` (the run's `detail`), `it` (its item), `i` (the row, `i.per[label]` for any run), `q` (question), `kind`. - **The conversation**, when the item ran the agentic multi-turn path — every turn in order, with the simulated user marked apart from a real question, so you can see diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index c6fbec456..adc5a1cea 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -101,8 +101,11 @@ position: sticky; top: 0; z-index: 10; margin: -18px -22px 12px; padding: 10px 22px; background: var(--bg); border-bottom: 1px solid var(--line); } - #filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } - #reset { cursor: pointer; color: var(--primary-ink); } + .filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; } + #filterbar .filters { margin-bottom: 0; } + .controls { display: contents; } + .fl { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); } + #top-reset, #items-reset { cursor: pointer; color: var(--primary-ink); } .scope { margin: 0 0 14px; padding: 6px 10px; font-size: 12px; background: var(--primary-tint); border-left: 3px solid var(--primary); border-radius: 0 3px 3px 0; @@ -131,11 +134,13 @@ } .multi .menu label { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 3px; cursor: pointer; } .multi .menu label:hover { background: var(--hover); } - .multi .menu label span { flex: 1 1 auto; font-family: var(--mono); font-size: 12px; } + .multi .menu label span { flex: 1 1 auto; font-size: 12.5px; } .multi .menu label i { font-style: normal; font-size: 11px; color: var(--dim); font-variant-numeric: tabular-nums; } .multi .menu input { margin: 0; padding: 0; } - .multi .menu #kindclear { width: 100%; margin-bottom: 4px; text-align: left; border: 0; background: none; color: var(--primary-ink); cursor: pointer; padding: 4px 8px; font-size: 12px; } - .multi .menu #kindclear:hover { background: var(--hover); } + .multi .menu label.zero { color: var(--dim); } + .multi .menu .clear { width: 100%; margin-bottom: 4px; text-align: left; border: 0; background: none; color: var(--primary-ink); cursor: pointer; padding: 4px 8px; font-size: 12px; } + .multi .menu .clear:hover { background: var(--hover); } + .runlbl { display: block; max-width: 18ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--dim); } #q { flex: 1 1 200px; min-width: 140px; } #expr { flex: 2 1 320px; min-width: 200px; font-family: var(--mono); font-size: 12px; } #expr.bad { border-color: var(--fail); background: var(--fail-bg); } @@ -182,39 +187,30 @@

-
- -
- any kind - -
- - - - - - +
+ Filters + + +
-
- expression vars: d detail of focused run · it focused run's item · i row (i.per[label]) · q question · kind -
-
+

Items

+

These filters narrow the table only, inside whatever the filters at the top allow.

+
+ + + + + +
+
+ expression vars: d detail of the run being read · it that run's item · i row (i.per[label]) · q question · kind — evaluated once per selected run; an item matches when any run does +
@@ -274,6 +270,7 @@

Items

const MODES = [['single_shot', 'Single-shot'], ['multi_turn', 'Multi-turn']]; const MODE_NAME = { single_shot: 'single-shot', multi_turn: 'multi-turn' }; const figures = {}; +let runsShown = LABELS; // the runs the dashboard covers; narrowed by the Runs filter at the top function reported(label) { const c = DATA.comparison[label] || {}, s = DATA.runs[label].summary || {}; @@ -331,7 +328,7 @@

Items

// ---------- summary cards ---------- function renderSummary() { - $('#summary').innerHTML = '

Runs

' + LABELS.map((label) => { + $('#summary').innerHTML = '

Runs

' + runsShown.map((label) => { const f = figures[label]; const rate = f.total ? f.passed / f.total : 0; const split = MODES.map(([m]) => [m, modeOf(label, m)]).filter(([, x]) => x && x.total) @@ -348,7 +345,7 @@

Items

// ---------- comparison ---------- function renderComparison() { - if (LABELS.length < 2) { $('#comparison').innerHTML = ''; return; } + if (runsShown.length < 2) { $('#comparison').innerHTML = ''; return; } // A selection with no items has no rate, quality or latency, rather than a 0% one. const some = (l, text) => (figures[l].total ? text : '–'); const cols = [ @@ -363,7 +360,7 @@

Items

// must scroll inside its own box rather than clip. $('#comparison').innerHTML = '

Comparison

' + cols.map(([h, , c]) => ``).join('') + '' + - LABELS.map((l) => '' + cols.map(([, f, c]) => ``).join('') + '').join('') + + runsShown.map((l) => '' + cols.map(([, f, c]) => ``).join('') + '').join('') + '
${h}
${f(l)}
${f(l)}
'; } @@ -385,7 +382,7 @@

Items

const heads = ['Run'].concat(counts ? ['Items', 'Passed'] : []) .concat([sample, 'Avg', 'P50 (median)', 'P70', 'P90', 'P99', 'Max', 'Range']); const dash = '–'; - const rows = LABELS.map((l) => { + const rows = runsShown.map((l) => { const m = modeOf(l, mode); const st = m ? m[field] : null; const lead = counts @@ -397,14 +394,14 @@

Items

: dash.repeat(7) + ''; return `${esc(l)}${lead}${stats}`; }).join(''); - const small = LABELS.some((l) => { const m = modeOf(l, mode); return m && m[field] && m[field].n < 100; }); + const small = runsShown.some((l) => { const m = modeOf(l, mode); return m && m[field] && m[field].n < 100; }); const unit = sample.toLowerCase(); return `
` + heads.map((h, i) => ``).join('') + `${rows}
${h}
` + (small ? `
With fewer than 100 ${unit}, P99 is effectively the slowest one.
` : ''); } function modeSection(mode, title, blurb, tables) { - if (!LABELS.some((l) => (modeOf(l, mode) || {}).total)) return ''; + if (!runsShown.some((l) => (modeOf(l, mode) || {}).total)) return ''; return `

${title}

${blurb}

` + tables.map(([caption, spec]) => (caption ? `

${caption}

` : '') + latencyTable({ mode, ...spec })).join(''); } @@ -443,39 +440,100 @@

Items

const latencyCell = (v, title) => `${secs(v)}`; // ---------- filters ---------- -const kinds = [...new Set(rows.map((r) => r.kind))].filter(Boolean).sort(); -// Kind is a multi-select: a
holding one checkbox per kind (with how many items it has), so it needs no -// widget library and stays keyboard-operable. Nothing ticked means every kind. -const kindCount = {}; -for (const r of rows) kindCount[r.kind] = (kindCount[r.kind] || 0) + 1; -$('#kindmenu').innerHTML = '' + kinds.map((k) => - ``).join(''); -const chosenKinds = () => [...document.querySelectorAll('#kindmenu input:checked')].map((i) => i.value); -function kindSummary() { - const c = chosenKinds(); - $('#kind > summary').textContent = c.length === 0 ? 'any kind' : c.length === 1 ? c[0] : `${c.length} kinds`; -} -$('#kind').addEventListener('input', kindSummary); -$('#kindclear').addEventListener('click', () => { - document.querySelectorAll('#kindmenu input').forEach((i) => { i.checked = false; }); - kindSummary(); - render(); -}); -document.addEventListener('click', (e) => { if (!$('#kind').contains(e.target)) $('#kind').open = false; }); -$('#kind').addEventListener('keydown', (e) => { - if (e.key === 'Escape' && $('#kind').open) { $('#kind').open = false; $('#kind > summary').focus(); } +// Two tiers. The bar at the top decides what the whole page covers; the filters in the Items section narrow the +// table further and touch nothing else, always inside the top selection. Kind, status and run are multi-selects: +// a
holding one checkbox per option, so they need no widget library and stay keyboard-operable. Nothing +// ticked means everything. +const STATUS_OPTIONS = [ + { value: 'pass', text: 'passed' }, { value: 'fail', text: 'failed' }, + { value: 'err', text: 'errored' }, { value: 'skip', text: 'skipped' }, +]; +function controlsHtml(p, runsTitle) { + const multi = (id, title) => + `
`; + const modes = [['', 'any mode'], ['single_shot', 'single-shot'], ['multi_turn', 'multi-turn']]; + return multi('kind', 'Kind; choose as many as you like') + + `` + + multi('status', 'Status; choose as many as you like. With several runs an item matches when it has that status in any of them') + + multi('runs', runsTitle); +} +$('#top-controls').innerHTML = controlsHtml('top', 'Runs; choose which ones the page shows'); +$('#items-controls').innerHTML = controlsHtml('items', + 'Runs; choose which ones the table shows and reads status and the expression from'); + +function makeMulti(el, noun, anyText, onChange) { + const summary = el.querySelector('summary'), menu = el.querySelector('.menu'); + const m = { el, sel: new Set(), opts: [], sig: null }; + const refresh = () => { + const picked = m.opts.filter((o) => m.sel.has(o.value)); + summary.textContent = picked.length === 0 ? anyText : picked.length === 1 ? picked[0].text : `${picked.length} ${noun}`; + }; + // Options can change under a selection (the table's kinds follow the top filters): a ticked value that is no + // longer offered is dropped, so what the summary says is what is applied. The menu is only rebuilt when the + // offered values change, so a checkbox keeps focus while it is being used. + m.set = (opts) => { + m.opts = opts; + for (const v of [...m.sel]) if (!opts.some((o) => o.value === v)) m.sel.delete(v); + const sig = opts.map((o) => o.value).join('\n'); + if (sig !== m.sig) { + m.sig = sig; + menu.innerHTML = '' + opts.map((o) => + ``).join(''); + } + menu.querySelectorAll('label').forEach((label, n) => { + const c = opts[n].count; + label.querySelector('i').textContent = c == null ? '' : c; + label.classList.toggle('zero', c === 0); + }); + refresh(); + }; + m.clear = () => { + m.sel.clear(); + menu.querySelectorAll('input').forEach((i) => { i.checked = false; }); + refresh(); + }; + menu.addEventListener('input', (e) => { + if (!e.target.matches('input')) return; + if (e.target.checked) m.sel.add(e.target.value); else m.sel.delete(e.target.value); + refresh(); + onChange(); + }); + menu.addEventListener('click', (e) => { if (e.target.matches('.clear')) { m.clear(); onChange(); } }); + el.addEventListener('toggle', () => { + if (el.open) document.querySelectorAll('.multi[open]').forEach((o) => { if (o !== el) o.open = false; }); + }); + return m; +} +document.addEventListener('click', (e) => { + document.querySelectorAll('.multi[open]').forEach((d) => { if (!d.contains(e.target)) d.open = false; }); }); -$('#reset').addEventListener('click', () => { - $('#q').value = ''; $('#expr').value = ''; $('#mode').value = ''; $('#status').value = 'all'; - document.querySelectorAll('#kindmenu input').forEach((i) => { i.checked = false; }); - kindSummary(); - render(); +document.addEventListener('keydown', (e) => { + const d = e.key === 'Escape' && document.querySelector('.multi[open]'); + if (d) { d.open = false; d.querySelector('summary').focus(); } }); -$('#focus').innerHTML = LABELS.map((l) => ``).join(''); + +const T = { // the filters at the top: they decide what the whole page covers + kind: makeMulti($('#top-kind'), 'kinds', 'any kind', render), + mode: $('#top-mode'), + status: makeMulti($('#top-status'), 'statuses', 'any status', render), + runs: makeMulti($('#top-runs'), 'runs', 'all runs', render), +}; +const I = { // the filters in the Items section: the table only + kind: makeMulti($('#items-kind'), 'kinds', 'any kind', renderItems), + mode: $('#items-mode'), + status: makeMulti($('#items-status'), 'statuses', 'any status', renderItems), + runs: makeMulti($('#items-runs'), 'runs', 'all runs', renderItems), +}; // With one run there is nothing to choose between. -$('#focus').hidden = LABELS.length < 2; -let focus = LABELS[0]; -let sortKey = 'id', sortDir = 1, selected = null; +$('#top-runs').hidden = $('#items-runs').hidden = LABELS.length < 2; +const kinds = [...new Set(rows.map((r) => r.kind))].filter(Boolean).sort(); +const countBy = (list) => { const c = {}; for (const r of list) c[r.kind] = (c[r.kind] || 0) + 1; return c; }; +T.kind.set(kinds.map((k) => ({ value: k, text: k, count: countBy(rows)[k] }))); +T.status.set(STATUS_OPTIONS); +T.runs.set(LABELS.map((l) => ({ value: l, text: l }))); +I.status.set(STATUS_OPTIONS); + +let sortKey = 'id', sortDir = 1, selected = null, drawerRun = null, drawerSig = ''; function exprFn() { const src = $('#expr').value.trim(); @@ -492,61 +550,95 @@

Items

} } -// The rows every table on the page covers. Kind, mode and search are properties of the item; status and the -// expression are read from the focused run. One selection for all runs keeps their columns comparable. -function matching() { - const text = $('#q').value.trim().toLowerCase(); - const picked = new Set(chosenKinds()), mode = $('#mode').value, status = $('#status').value, fn = exprFn(); - let bad = false; - const out = rows.filter((r) => { - const it = r.per[focus]; - if (picked.size && !picked.has(r.kind)) return false; +// Kind, mode and status for one tier. Kind and mode are properties of the item; status is read from the runs in +// play and matches when the item has a chosen status in any of them. A row must exist in at least one of those runs. +function rowMatcher({ kinds: ks, mode, statuses }, runList) { + return (r) => { + if (!runList.some((l) => r.per[l])) return false; + if (ks.size && !ks.has(r.kind)) return false; if (mode && r.mode !== mode) return false; - if (status !== 'all' && statusOf(it) !== status) return false; + if (statuses.size && !runList.some((l) => statuses.has(statusOf(r.per[l])))) return false; + return true; + }; +} +let dashRows = rows; // the rows the whole page covers: everything that passes the filters at the top +const itemRuns = () => (I.runs.sel.size ? runsShown.filter((l) => I.runs.sel.has(l)) : runsShown); +// The rows in the table: the top selection, narrowed by the table's own filters. +function itemRows(runList) { + const text = $('#q').value.trim().toLowerCase(), fn = exprFn(); + const base = rowMatcher({ kinds: I.kind.sel, mode: I.mode.value, statuses: I.status.sel }, runList); + let bad = false; + const out = dashRows.filter((r) => { + if (!base(r)) return false; if (text && !(r.id + ' ' + r.question).toLowerCase().includes(text)) return false; if (fn) { - try { if (!fn(it && it.detail || {}, it || {}, r, r.question, r.kind)) return false; } - catch (e) { bad = true; return false; } + const hit = runList.filter((l) => r.per[l]).some((l) => { + const it = r.per[l]; + try { return fn(it.detail || {}, it, r, r.question, r.kind); } catch (e) { bad = true; return false; } + }); + if (!hit) return false; } return true; }); if (bad) $('#expr').classList.add('bad'); return out; } -function sortRows(list) { - const key = sortKey; +function activeSortKey(runList) { + const c = sortKey.indexOf(':'); + return c < 0 || runList.includes(sortKey.slice(c + 1)) ? sortKey : 'id'; +} +function sortRows(list, key) { return [...list].sort((a, b) => { const va = sortVal(a, key), vb = sortVal(b, key); return (va < vb ? -1 : va > vb ? 1 : 0) * sortDir; }); } function sortVal(r, key) { - if (key === 'id' || key === 'kind' || key === 'mode' || key === 'question') return r[key] || ''; - if (key === 'latency_trace') return traceLatency(r.per[focus]) ?? -1; - if (key === 'latency_run') return runLatency(r.per[focus]) ?? -1; - return LBL[statusOf(r.per[key])] || ''; // a run column + const c = key.indexOf(':'); + if (c < 0) return r[key] || ''; // id, kind, mode, question + const tag = key.slice(0, c), it = r.per[key.slice(c + 1)]; + if (tag === 'lt') return traceLatency(it) ?? -1; + if (tag === 'lr') return runLatency(it) ?? -1; + return LBL[statusOf(it)] || ''; // a run's status column } -let shown = []; -const filtersActive = () => !!($('#q').value.trim() || $('#expr').value.trim() || chosenKinds().length || - $('#mode').value || $('#status').value !== 'all'); +const topActive = () => !!(T.kind.sel.size || T.mode.value || T.status.sel.size || T.runs.sel.size); +const itemsActive = () => !!($('#q').value.trim() || $('#expr').value.trim() || I.kind.sel.size || I.mode.value || + I.status.sel.size || I.runs.sel.size); function renderScope() { - const active = filtersActive(); - $('#count').textContent = `${shown.length} of ${rows.length} items`; - $('#reset').hidden = !active; - const readsFocus = LABELS.length > 1 && ($('#status').value !== 'all' || $('#expr').value.trim()); - $('#scope').hidden = !active; - $('#scope').innerHTML = active - ? `Filtered: every table on this page covers these ${shown.length} of ${rows.length} items.` + - (readsFocus ? ` Status and expression are read from ${esc(focus)}.` : '') + $('#top-count').textContent = `${dashRows.length} of ${rows.length} items`; + $('#top-reset').hidden = !topActive(); + const parts = []; + if (dashRows.length !== rows.length) parts.push(`the Runs, Comparison, Latency and Items sections cover these ${dashRows.length} of ${rows.length} items`); + if (runsShown.length !== LABELS.length) parts.push(`showing ${runsShown.length} of ${LABELS.length} runs`); + const several = runsShown.length > 1 && T.status.sel.size; + // Ticking every run is the same as ticking none: say nothing unless the selection really is narrower. + $('#scope').hidden = parts.length === 0; + $('#scope').innerHTML = parts.length + ? `Filtered: ${parts.join('; ')}.` + (several ? ' With several runs, an item matches a status when any of the runs shown has it.' : '') : ''; } -// Everything on the page follows the filters. Sorting and selecting a row only touch the item table. +// The table's own options follow the top selection: only kinds that survive it are offered (with how many items +// each has there), only the runs on show, and only the modes still present. +function syncItemControls() { + const byKind = countBy(dashRows); + I.kind.set(Object.keys(byKind).filter(Boolean).sort().map((k) => ({ value: k, text: k, count: byKind[k] }))); + I.runs.set(runsShown.map((l) => ({ value: l, text: l }))); + const present = new Set(dashRows.map((r) => r.mode)); + const keep = I.mode.value; + I.mode.innerHTML = '' + MODES.filter(([m]) => present.has(m)) + .map(([m]) => ``).join(''); + I.mode.value = present.has(keep) ? keep : ''; +} +// Everything on the page follows the top filters. Sorting, the table's filters and selecting a row only touch the +// item table (renderItems). function render() { - shown = matching(); - const filtered = shown.length !== rows.length; - const ids = shown.map((r) => r.id); - for (const l of LABELS) figures[l] = filtered ? recompute(l, ids) : reported(l); + runsShown = T.runs.sel.size ? LABELS.filter((l) => T.runs.sel.has(l)) : LABELS; + dashRows = rows.filter(rowMatcher({ kinds: T.kind.sel, mode: T.mode.value, statuses: T.status.sel }, runsShown)); + const filtered = dashRows.length !== rows.length; + const ids = dashRows.map((r) => r.id); + for (const l of runsShown) figures[l] = filtered ? recompute(l, ids) : reported(l); + syncItemControls(); renderScope(); renderSummary(); renderComparison(); @@ -554,24 +646,37 @@

Items

renderItems(); } function renderItems() { - const list = sortRows(shown); - const heads = [['id', 'Item'], ['kind', 'Kind'], ['mode', 'Mode'], ['question', 'Question']] - .concat(LABELS.map((l) => [l, l])) - .concat([['latency_trace', 'Latency / trace', TRACE_HELP], ['latency_run', 'Latency / run', RUN_HELP]]); - $('#items thead').innerHTML = '' + heads.map(([k, h, help]) => - `${esc(h)}${sortKey === k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; - $('#items tbody').innerHTML = list.map((r) => { - const it = r.per[focus] || {}; - return ` + const runList = itemRuns(); + const list = sortRows(itemRows(runList), activeSortKey(runList)); + const key = activeSortKey(runList), multi = runList.length > 1; + $('#items-count').textContent = `${list.length} of ${dashRows.length} items`; + $('#items-reset').hidden = !itemsActive(); + const runHdr = (l) => (multi ? `${esc(l)}` : ''); + const heads = [ + { k: 'id', html: 'Item' }, { k: 'kind', html: 'Kind' }, { k: 'mode', html: 'Mode' }, { k: 'question', html: 'Question' }, + ...runList.map((l) => ({ k: 's:' + l, html: esc(l) })), + ...runList.map((l) => ({ k: 'lt:' + l, html: 'Latency / trace' + runHdr(l), help: TRACE_HELP, num: true })), + ...runList.map((l) => ({ k: 'lr:' + l, html: 'Latency / run' + runHdr(l), help: RUN_HELP, num: true })), + ]; + $('#items thead').innerHTML = '' + heads.map((h) => + `${h.html}${key === h.k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; + $('#items tbody').innerHTML = list.map((r) => + ` ${esc(r.id)} ${esc(r.kind)} ${r.mode ? `${esc(MODE_NAME[r.mode] || r.mode)}` : ''} ${esc(r.question)}` + - LABELS.map((l) => `${chipHtml(statusOf(r.per[l]))}`).join('') + - latencyCell(traceLatency(it), samplesTitle(it.trace_latencies_s, 'trace')) + - latencyCell(runLatency(it), samplesTitle(it.run_latencies_s, 'run')) + ''; - }).join(''); + runList.map((l) => `${chipHtml(statusOf(r.per[l]))}`).join('') + + runList.map((l) => latencyCell(traceLatency(r.per[l]), samplesTitle((r.per[l] || {}).trace_latencies_s, 'trace'))).join('') + + runList.map((l) => latencyCell(runLatency(r.per[l]), samplesTitle((r.per[l] || {}).run_latencies_s, 'run'))).join('') + + '').join(''); $('#noitems').hidden = list.length > 0; + // The drawer follows the table's runs: if the run it shows was filtered out, move it to one that is still there. + const row = selected && byId.get(selected); + if (row && $('#drawer').classList.contains('open') && (pickDrawerRun(row) !== drawerRun || drawerSig !== runList.join('\n'))) { + drawerRun = pickDrawerRun(row); + openDrawer(row); + } } function sortBy(k) { @@ -579,8 +684,15 @@

Items

sortKey = k; renderItems(); } +// The run the drawer shows: the one it already shows if the table still includes it and the item ran there. +function pickDrawerRun(r) { + const runList = itemRuns(); + if (drawerRun && runList.includes(drawerRun) && r.per[drawerRun]) return drawerRun; + return runList.find((l) => r.per[l]) || runList[0]; +} function openRow(id) { - selected = id; renderItems(); openDrawer(byId.get(id)); + const r = byId.get(id); + selected = id; drawerRun = pickDrawerRun(r); renderItems(); openDrawer(r); } $('#items thead').addEventListener('click', (e) => { const k = e.target.closest('th')?.dataset.k; @@ -598,11 +710,16 @@

Items

const id = e.target.closest('tr')?.dataset.id; if (id && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openRow(id); } }); -for (const id of ['#q', '#expr', '#kind', '#mode', '#status']) $(id).addEventListener('input', render); -$('#focus').addEventListener('change', () => { - focus = $('#focus').value; +$('#top-mode').addEventListener('input', render); +for (const id of ['#items-mode', '#q', '#expr']) $(id).addEventListener('input', renderItems); +$('#top-reset').addEventListener('click', () => { + T.kind.clear(); T.status.clear(); T.runs.clear(); T.mode.value = ''; render(); - if (selected) openDrawer(byId.get(selected)); +}); +$('#items-reset').addEventListener('click', () => { + I.kind.clear(); I.status.clear(); I.runs.clear(); I.mode.value = ''; + $('#q').value = ''; $('#expr').value = ''; + renderItems(); }); // ---------- drawer ---------- @@ -627,13 +744,29 @@

Items

} function openDrawer(r) { if (!r) return; - const it = r.per[focus]; + const run = drawerRun, runList = itemRuns(); + const it = r.per[run]; const d = $('#drawer'); d.classList.add('open'); - if (!it) { d.innerHTML = `

${esc(r.id)}

Not present in ${esc(focus)}.

`; return; } + drawerSig = runList.join('\n'); + // With several runs in the table the drawer says which one it shows and lets you switch. + const heading = runList.length > 1 + ? `` + : `

${esc(run)}

`; + const wire = () => { + d.querySelector('.close').onclick = () => { d.classList.remove('open'); selected = null; renderItems(); }; + const pick = d.querySelector('#drawerrun'); + if (pick) pick.onchange = () => { drawerRun = pick.value; openDrawer(r); }; + }; + if (!it) { + d.innerHTML = `${heading}
${esc(r.id)}

Not present in ${esc(run)}.

`; + wire(); + return; + } const st = statusOf(it); d.innerHTML = ` -

${esc(focus)}

+ ${heading}
${esc(r.id)}

${esc(r.question)}

${chipHtml(st)} @@ -648,7 +781,7 @@

${esc(focus)}

${timelineHtml(it)} ${reasoningHtml(it)} ${idsHtml(it)}`; - d.querySelector('.close').onclick = () => { d.classList.remove('open'); selected = null; renderItems(); }; + wire(); d.querySelectorAll('.tl .row').forEach((el) => { el.onclick = () => { const box = $('#tl-detail'); diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index 81dca672e..b454f58ee 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -264,8 +264,8 @@ def test_the_item_table_names_what_each_latency_column_measures(): "Latency / run", "trace_latencies_s", "run_latencies_s", - "latency_trace", - "latency_run", + "'lt:' + l", + "'lr:' + l", ): assert text in html assert "['latency', 'Latency']" not in html @@ -302,14 +302,40 @@ def test_an_item_is_judged_by_the_gate_verdict_not_pass_at_k_alone(): assert "item.pass_at_k ? 'pass'" not in html -def test_the_kind_filter_is_a_multi_select(): +def test_kind_status_and_run_are_multi_selects_in_both_tiers(): html = build_html(_doc_with_modes("gpt-5")) - assert '
start, f"{control} must live in the Items section" + # The dashboard's render path never reads the table's own filters. + render = html.split("function render() {")[1].split("function renderItems")[0] + for table_only in ("$('#q')", "$('#expr')", "I.kind", "I.status", "I.runs", "I.mode"): + assert table_only not in render + + +def test_the_tables_filters_combine_with_the_top_ones_without_widening_them(): + """The table starts from the rows the top filters allow (`dashRows`) and only narrows them.""" + html = build_html(_doc_with_modes("gpt-5")) + + items = html.split("function itemRows(runList) {")[1].split("function activeSortKey")[0] + assert "dashRows.filter(" in items + assert "rows.filter(" not in items def test_html_uses_the_goodata_tokens_and_loads_no_web_font(): From 8ebedf0b3e41b794ccbcfce82c7605111f6ab7d4 Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Sun, 11 Oct 2026 23:48:07 +0200 Subject: [PATCH 7/9] feat(gooddata-eval): add a trimmed mean to the latency distributions P50 is the median, so the average and P50 are the only central figures the latency tables had, and a handful of very slow or very fast samples move the first while the second ignores everything but the middle. Add trimmed_mean to every latency distribution: the average of the samples left after dropping the fastest and slowest 10% from each end (n * 10 // 100 of them, in integer arithmetic). It is null below ten samples, where there is nothing to drop and it would only repeat mean. It is computed in latency_stats, so the JSON and the HTML agree, and the HTML recomputes it by the same rule for a filtered view. The latency tables show it as "Trimmed avg" with a tooltip, and say when a sample is too small. The console line is unchanged: it carries percentiles only. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 14 ++-- .../core/reporting/report_template.html | 24 +++++-- .../src/gooddata_eval/core/timing.py | 28 +++++++- .../gooddata-eval/tests/test_html_report.py | 13 ++++ .../gooddata-eval/tests/test_reporting.py | 1 + packages/gooddata-eval/tests/test_timing.py | 69 ++++++++++++++++++- 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index d9cc1d5aa..9ac21ac65 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -324,10 +324,10 @@ their eval kind, and states latency for each: "summary": { "by_mode": { "single_shot": {"total": 12, "passed": 10, - "traces_s": {"n": 24, "mean": 5.39, "min": 3.88, "p50": 5.33, "p70": 6.23, "p90": 6.82, "p99": 7.18, "max": 7.22}}, + "traces_s": {"n": 24, "mean": 5.39, "trimmed_mean": 5.36, "min": 3.88, "p50": 5.33, "p70": 6.23, "p90": 6.82, "p99": 7.18, "max": 7.22}}, "multi_turn": {"total": 6, "passed": 4, - "traces_s": {"n": 45, "mean": 6.91, "min": 2.78, "p50": 6.52, "p70": 8.03, "p90": 10.79, "p99": 13.57, "max": 14.25}, - "runs_s": {"n": 15, "mean": 23.55, "min": 12.64, "p50": 21.23, "p70": 28.52, "p90": 33.02, "p99": 36.94, "max": 37.51}} + "traces_s": {"n": 45, "mean": 6.91, "trimmed_mean": 6.84, "min": 2.78, "p50": 6.52, "p70": 8.03, "p90": 10.79, "p99": 13.57, "max": 14.25}, + "runs_s": {"n": 15, "mean": 23.55, "trimmed_mean": 23.31, "min": 12.64, "p50": 21.23, "p70": 28.52, "p90": 33.02, "p99": 36.94, "max": 37.51}} } } ``` @@ -350,6 +350,10 @@ Each item also states its `mode`, its `trace_latencies_s` and its `run_latencies - **Percentiles** use linear interpolation between closest ranks (NumPy's default), so `p50` is the median and the report carries no separate median field. On a small sample the high percentiles sit at or near the maximum: P99 over fewer than 100 samples is effectively the slowest one, which is why `n` always travels with it. +- **Trimmed mean** (`trimmed_mean`) is the average of the middle 80%: the fastest 10% and the slowest 10% of the + samples are dropped from each end (`n * 10 // 100` of them, so 25 samples drop two each) before averaging. It is + not the median, which is one middle value (`p50`). It is `null` below 10 samples, where there is nothing to + trim and the figure would only repeat `mean`. - **Older reports** have no `by_mode`. The HTML report says so and nothing is backfilled. `avg_latency_s` and the other existing fields are unchanged. The console prints each run's pass counts per @@ -399,8 +403,8 @@ page from the selected items' own records, by the same rules gd-eval uses. It gi single-shot and multi-turn and the single-shot per-trace median and tail on each card. - **Latency sections** — **Single-shot · latency per trace** first, as the main figure, then **Multi-turn**, which shows both per trace and per full run. Each is a table with one row per run: P50 (the median), P70, P90, P99, - the average, the maximum, the number of samples and a strip marking where the percentiles fall between the - fastest and slowest sample. They are read from `by_mode`, never recomputed in the page. + the average, a trimmed average (the middle 80%), the maximum, the number of samples and a strip marking where + the percentiles fall between the fastest and slowest sample. Unfiltered they are read from `by_mode`; a filtered view recomputes them in the page by the same rules. - **An item table** with a pass/fail column per run, so a model or run-over-run regression is one glance rather than a hand-assembled spreadsheet. A mode column separates single-shot from multi-turn items. Two latency columns diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index adc5a1cea..0997ad4dd 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -293,7 +293,10 @@

Items

function latencyStats(samples) { const s = [...samples].sort((a, b) => a - b); if (!s.length) return null; - return { n: s.length, mean: r3(mean(s)), min: r3(s[0]), p50: r3(percentile(s, 50)), p70: r3(percentile(s, 70)), + // The mean without the fastest and slowest 10%; null while a sample is too small to drop one from each end. + const drop = Math.floor(s.length / 10); + const trimmed = drop ? mean(s.slice(drop, s.length - drop)) : null; + return { n: s.length, mean: r3(mean(s)), trimmed_mean: trimmed === null ? null : r3(trimmed), min: r3(s[0]), p50: r3(percentile(s, 50)), p70: r3(percentile(s, 70)), p90: r3(percentile(s, 90)), p99: r3(percentile(s, 99)), max: r3(s[s.length - 1]) }; } // Fraction of the best run's boolean checks that held; with none, 1 or 0 by the verdict. @@ -378,9 +381,14 @@

Items

` + ticks.map(([k, v]) => ``).join('') + '
'; } +const HEAD_HELP = { + Avg: 'Mean of every sample.', + 'Trimmed avg': 'Mean of the middle 80%: the fastest 10% and the slowest 10% of samples are dropped first. Needs at least 10 samples.', + 'P50 (median)': 'The median: half the samples are faster, half slower.', +}; function latencyTable({ mode, field, sample, counts }) { const heads = ['Run'].concat(counts ? ['Items', 'Passed'] : []) - .concat([sample, 'Avg', 'P50 (median)', 'P70', 'P90', 'P99', 'Max', 'Range']); + .concat([sample, 'Avg', 'Trimmed avg', 'P50 (median)', 'P70', 'P90', 'P99', 'Max', 'Range']); const dash = '–'; const rows = runsShown.map((l) => { const m = modeOf(l, mode); @@ -389,16 +397,20 @@

Items

? (m && m.total ? `${m.total}${m.passed}/${m.total} (${pct(m.passed / m.total)})` : `0${dash}`) : ''; const stats = st - ? [st.n, secs(st.mean), secs(st.p50), secs(st.p70), secs(st.p90), secs(st.p99), secs(st.max)] + ? [st.n, secs(st.mean), secs(st.trimmed_mean), secs(st.p50), secs(st.p70), secs(st.p90), secs(st.p99), secs(st.max)] .map((v) => `${v}`).join('') + `${stripHtml(st)}` - : dash.repeat(7) + ''; + : dash.repeat(8) + ''; return `${esc(l)}${lead}${stats}`; }).join(''); const small = runsShown.some((l) => { const m = modeOf(l, mode); return m && m[field] && m[field].n < 100; }); + const untrimmed = runsShown.some((l) => { const m = modeOf(l, mode); return m && m[field] && m[field].trimmed_mean == null; }); const unit = sample.toLowerCase(); + const notes = []; + if (small) notes.push(`With fewer than 100 ${unit}, P99 is effectively the slowest one.`); + if (untrimmed) notes.push('Trimmed avg needs at least 10 samples.'); return `
` + - heads.map((h, i) => ``).join('') + `${rows}
${h}
` + - (small ? `
With fewer than 100 ${unit}, P99 is effectively the slowest one.
` : ''); + heads.map((h, i) => `${h}`).join('') + `${rows}
` + + (notes.length ? `
${notes.join(' ')}
` : ''); } function modeSection(mode, title, blurb, tables) { if (!runsShown.some((l) => (modeOf(l, mode) || {}).total)) return ''; diff --git a/packages/gooddata-eval/src/gooddata_eval/core/timing.py b/packages/gooddata-eval/src/gooddata_eval/core/timing.py index cffae216e..a9248f912 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/timing.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/timing.py @@ -99,18 +99,42 @@ def percentile(sorted_samples: Sequence[float], p: float) -> float: return sorted_samples[lower] + (sorted_samples[upper] - sorted_samples[lower]) * (rank - lower) -def latency_stats(samples: Iterable[float]) -> dict[str, float] | None: +# Share of the sample dropped from EACH end before the trimmed mean: 10% leaves the middle 80%. +TRIM_PERCENT = 10 + + +def trimmed_mean(sorted_samples: Sequence[float], percent: int = TRIM_PERCENT) -> float | None: + """Mean of what is left after dropping the fastest and slowest ``percent`` % of the sample. + + Not the median: the median is one middle value (P50), this still averages, but without the + outliers at either end. ``None`` when the sample is too small to drop at least one value + from each end (fewer than ``100 / percent`` samples) -- the figure would silently be the + plain mean. The cut is ``n * percent // 100`` from each end, so 25 samples drop two each. + """ + if not sorted_samples: + raise ValueError("trimmed mean of an empty sample") + drop = len(sorted_samples) * percent // 100 + if drop == 0: + return None + kept = sorted_samples[drop : len(sorted_samples) - drop] + return sum(kept) / len(kept) + + +def latency_stats(samples: Iterable[float]) -> dict[str, float | None] | None: """Distribution of per-run latencies in seconds, or ``None`` when there are none. ``n`` is the number of runs the figures are over: the percentiles are only as sharp as - that sample, and a reader needs it to judge how far to trust P99. + that sample, and a reader needs it to judge how far to trust P99. ``trimmed_mean`` is + ``None`` until there are enough samples to trim. """ ordered = sorted(samples) if not ordered: return None + trimmed = trimmed_mean(ordered) return { "n": len(ordered), "mean": round(sum(ordered) / len(ordered), 3), + "trimmed_mean": round(trimmed, 3) if trimmed is not None else None, "min": round(ordered[0], 3), **{f"p{p}": round(percentile(ordered, p), 3) for p in LATENCY_PERCENTILES}, "max": round(ordered[-1], 3), diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index b454f58ee..73f481f45 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -338,6 +338,19 @@ def test_the_tables_filters_combine_with_the_top_ones_without_widening_them(): assert "rows.filter(" not in items +def test_the_latency_tables_show_a_trimmed_average_beside_the_average_and_median(): + """The trimmed average is a different figure from the median (P50): it still averages, without the + fastest and slowest tenth. The page reads it from the report and recomputes it only for a filtered view.""" + html = build_html(_doc_with_modes("gpt-5")) + + assert "Trimmed avg" in html + assert "P50 (median)" in html + assert "st.trimmed_mean" in html + # A filtered view recomputes it by the same rule gd-eval uses, and says when a sample is too small. + assert "trimmed_mean:" in html.split("function latencyStats(samples)")[1].split("function qualityOf")[0] + assert "Trimmed avg needs at least 10 samples." in html + + def test_html_uses_the_goodata_tokens_and_loads_no_web_font(): html = build_html(_doc("gpt-5", True)) diff --git a/packages/gooddata-eval/tests/test_reporting.py b/packages/gooddata-eval/tests/test_reporting.py index 4d7edaf89..317ceb220 100644 --- a/packages/gooddata-eval/tests/test_reporting.py +++ b/packages/gooddata-eval/tests/test_reporting.py @@ -532,6 +532,7 @@ def test_json_report_splits_pass_counts_and_latency_by_mode(): assert single["traces_s"] == { "n": 2, "mean": 2.0, + "trimmed_mean": None, "min": 1.0, "p50": 2.0, "p70": 2.4, diff --git a/packages/gooddata-eval/tests/test_timing.py b/packages/gooddata-eval/tests/test_timing.py index 30dc6f26c..5b1f58ed5 100644 --- a/packages/gooddata-eval/tests/test_timing.py +++ b/packages/gooddata-eval/tests/test_timing.py @@ -17,6 +17,7 @@ percentile, sum_timings, timers_enabled, + trimmed_mean, ) @@ -130,7 +131,17 @@ def test_latency_stats_of_nothing_is_none(): def test_latency_stats_orders_an_unsorted_sample_and_reports_every_figure(): stats = latency_stats([4.0, 1.0, 3.0, 2.0]) - assert stats == {"n": 4, "mean": 2.5, "min": 1.0, "p50": 2.5, "p70": 3.1, "p90": 3.7, "p99": 3.97, "max": 4.0} + assert stats == { + "n": 4, + "mean": 2.5, + "trimmed_mean": None, + "min": 1.0, + "p50": 2.5, + "p70": 3.1, + "p90": 3.7, + "p99": 3.97, + "max": 4.0, + } def test_latency_stats_never_decreases_from_p50_to_max(): @@ -143,7 +154,61 @@ def test_latency_stats_never_decreases_from_p50_to_max(): def test_latency_stats_of_one_run_collapses_to_that_run(): stats = latency_stats([3.25]) - assert stats == {"n": 1, "mean": 3.25, "min": 3.25, "p50": 3.25, "p70": 3.25, "p90": 3.25, "p99": 3.25, "max": 3.25} + assert stats == { + "n": 1, + "mean": 3.25, + "trimmed_mean": None, + "min": 3.25, + "p50": 3.25, + "p70": 3.25, + "p90": 3.25, + "p99": 3.25, + "max": 3.25, + } + + +def test_trimmed_mean_drops_the_fastest_and_slowest_tenth_before_averaging(): + # Ten samples drop one from each end: the 0.1s and the 100s no longer move the average. + samples = [0.1, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 100.0] + + assert trimmed_mean(samples) == pytest.approx(2.0) + assert sum(samples) / len(samples) == pytest.approx(11.61) # the plain mean is dragged far above it + + +def test_trimmed_mean_is_not_the_median(): + # The median is one middle value; the trimmed mean still averages what is left, so the two + # differ whenever the middle 80% is not symmetric. + samples = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 9.0, 9.0] + + assert statistics.median(samples) == 1.0 + assert trimmed_mean(samples) == pytest.approx(2.0) + + +@pytest.mark.parametrize(("n", "dropped_each_end"), [(10, 1), (19, 1), (20, 2), (25, 2), (100, 10)]) +def test_trimmed_mean_cut_is_the_floor_of_a_tenth_of_the_sample_per_end(n, dropped_each_end): + samples = [float(i) for i in range(n)] + + kept = samples[dropped_each_end : n - dropped_each_end] + assert trimmed_mean(samples) == pytest.approx(sum(kept) / len(kept)) + + +@pytest.mark.parametrize("n", [1, 2, 5, 9]) +def test_trimmed_mean_is_none_when_nothing_can_be_trimmed(n): + # Below ten samples a 10% trim removes nothing, so the figure would just be the plain mean + # under a name that promises otherwise. + assert trimmed_mean([float(i) for i in range(n)]) is None + + +def test_trimmed_mean_of_an_empty_sample_raises(): + with pytest.raises(ValueError, match="empty"): + trimmed_mean([]) + + +def test_latency_stats_carries_the_trimmed_mean_once_there_are_ten_samples(): + stats = latency_stats([100.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 0.1]) + + assert stats["trimmed_mean"] == 2.0 + assert stats["mean"] == 11.61 def test_a_recorder_collects_each_noted_trace_in_order(): From 60dad1e781f1af49f322d991173a3663e08ecd4e Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Mon, 12 Oct 2026 12:13:36 +0200 Subject: [PATCH 8/9] feat(gooddata-eval): redesign the HTML report as a readable document The report opened on run cards and a flat table, and spoke in the vocabulary of the harness (item, kind, trace, run, P50, P99). Lay it out as a document a reader can take in top to bottom, in plain words. A masthead names the report and carries a Run setup tooltip: what is the same in every run and what differs, because results from different setups are not directly comparable. Results is three headline cards for the latest run (pass rate, quality, median time per attempt), each with one change line in words ("down from 86% on Sep 28") coloured by whether the direction is good, with a chevron and screen-reader text. From three runs each card has a sparkline; clicking a point compares the latest run with that one, and a pick that a filter removed or that became the latest run is ignored instead of compared with itself. The latency tables read Fastest, Median, P90 and Slowest, newest run first, with the quickest run in each column green and the slowest red (each with a chevron) and a caution icon when a run was timed on a different number of samples. P70, P99, the average and the trimmed average stay in the JSON and are still recomputed for a filtered view; they are no longer shown. Seconds drop a redundant ".0". The test case table lists the latest result, a Since previous run column (Regressed, Fixed, Flaky, New, Removed; the table opens on regressions), then earlier runs going back. The kind, status and run filters follow the product's attribute filter (search, All, Only, Cancel, Apply, staged until Apply), the expression filter is folded under Advanced, and a print stylesheet drops the filters and the drawer. An explicit --title is honoured, and a setup field a redacted report does not carry is not listed as an empty row. Faces are Inter and Funnel Display, named but never loaded, so the file stays self-contained. Tests that pinned the old layout now pin the new one, and the README describes it. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 93 ++- .../core/reporting/report_template.html | 690 +++++++++++++----- .../gooddata-eval/tests/test_html_report.py | 163 ++++- 3 files changed, 705 insertions(+), 241 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index 9ac21ac65..485c05a52 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -366,8 +366,9 @@ P50/P90/P99 columns (single-shot) in the multi-model comparison. Turns JSON report(s) into one HTML file you can actually navigate. No server, no credentials, no external assets — it opens over `file://`, attaches to a Jira issue and -survives a Slack thread. It follows GoodData's product look (colours, type, status chips), using a system -font stack rather than an embedded web font so the file stays self-contained. +survives a Slack thread. It follows GoodData's product look (colours, type, status chips). The faces are Inter, with +Funnel Display for headings, named but never loaded, so a viewer without them gets the system sans and the file stays +self-contained. ```bash # one run @@ -388,45 +389,71 @@ gd-eval report results.json -o customer.html --redact The report is a *view* over the JSON. Unfiltered, every figure on it is read from the report, so it cannot disagree with the console or the JSON. Narrow the selection with a filter and the figures are recomputed in the -page from the selected items' own records, by the same rules gd-eval uses. It gives you: - -- **Filters in two tiers** — the bar at the top (kind, mode, status and run) decides what the whole page covers: - the run cards, the comparison, the latency tables and the item table all follow it, so the runs stay - comparable. The Items section repeats kind, mode, status and run and adds the search box and the expression; - those narrow the table only, inside whatever the top bar allows, and only offer what the top bar leaves (a - kind the top filters removed disappears from the table's menu and its selection). Kind, status and run are - multi-selects; nothing ticked means all. The run filter chooses which runs are shown; a status matches an item - when any of the runs in play has it (so "failed or errored" over previous and current finds every item that - broke in either), and the expression is evaluated once per run in play with the same any-run rule. A filtered - page states how many items it covers, and each tier has its own Reset. -- **Run cards and a comparison table** — pass rate and quality per run, with the pass counts split by - single-shot and multi-turn and the single-shot per-trace median and tail on each card. -- **Latency sections** — **Single-shot · latency per trace** first, as the main figure, then **Multi-turn**, which - shows both per trace and per full run. Each is a table with one row per run: P50 (the median), P70, P90, P99, - the average, a trimmed average (the middle 80%), the maximum, the number of samples and a strip marking where - the percentiles fall between the fastest and slowest sample. Unfiltered they are read from `by_mode`; a filtered view recomputes them in the page by the same rules. -- **An item table** with a pass/fail column per run, so a model or run-over-run - regression is one glance rather than a hand-assembled spreadsheet. A mode column separates - single-shot from multi-turn items. Two latency columns - keep the populations apart: **Latency / trace** is the median response time of the item's chat turns, and - **Latency / run** is the mean wall-clock of a whole run (the chat turns plus the judge and, for multi-turn, - the simulated user). Hover a value to see every sample behind it; the drawer labels its two latency chips - the same way. A report written before per-trace timing has no trace figure and shows `avg_latency_s` as the - run figure. -- **An expression filter** (in the Items section) for cross-cutting questions the fixed filters can't - anticipate, e.g. `d.filter_ranking_score === false` or +page from the selected items' own records, by the same rules gd-eval uses. The page reads top to bottom as a +document, in plain words: a *test case* is one prompt given to the assistant, an *attempt* is one go at it, and the +two formats are *single-question* tests (one answer, no follow-up) and *conversation* tests (a back-and-forth with a +simulated user), which are `single_shot` and `multi_turn` in the JSON. + +- **A masthead** with the report's title (`--title`, or "Run history", "Run comparison" or "Run results" plus the + model and workspace), when it was generated in the reader's own time zone, and a **Run setup** tooltip listing what + is the same in every run (model, provider, workspace, dataset, test cases, attempts per test case, pass rule) and + what differs. Results from different setups are not directly comparable, so a caution icon on a latency row and the + *New* / *Removed* marks in the test case table say where two runs stopped being alike. +- **Filters in two tiers** — the **Filter the report** bar at the top (category, format, result and run) decides what + the whole page covers: the Results cards, the latency tables and the test case table all follow it, so the runs stay + comparable. The Test cases section repeats those four and adds the search box and the expression; they narrow the + table only, inside whatever the top bar allows, and only offer what the top bar leaves. Category, result and run are + attribute filters like the product's: a search box, an All row, the values with an **Only** shortcut and a count, + then Cancel and Apply. Changes are staged in the popup and take effect on Apply; closing it any other way discards + them. Nothing ticked means all. The run filter chooses which runs are shown; a result matches a test case when any of + the runs in play has it (so "Failed" over previous and latest finds every test case that broke in either), and the + expression is evaluated once per run in play with the same any-run rule. A filtered page states how many test cases + it covers, and each tier has its own Reset. +- **Results** — three headline cards for the latest run: test cases passed, the average quality score and the median + time per attempt. With an earlier run each card carries one change line in words, "down from 86% on Sep 28", rather + than a signed number. The line is green when that direction is good and red when it is not, with a chevron and + screen-reader text (*Better* / *Worse*) so colour is not the only signal. From three runs each card also has a + sparkline of every run: hover a point for its value, click it to compare the latest run with that one instead of the + previous. A pick that a filter has removed, or that has become the latest run, falls back to the previous run. The + pass-rate card links to the test case table to see *which* ones changed; the split by format lives in that table's + filters. +- **Latency sections** — **Single-question tests · response time** first, then **Conversation tests**, which shows + both per response (each assistant reply, one gen-ai trace) and per conversation (start to finish, with the + simulated user and the judge). Each is a table with one row per run, newest first, giving the fastest, the median + (P50), P90 and the slowest. The quickest run in each column is marked green and the slowest red, each with a chevron + (down for least time, up for most) so colour is not the only signal, and the latest run is marked in lime. A caution + icon flags a run timed on a different number of samples than the one before. Unfiltered they are read from + `by_mode`; a filtered view recomputes them in the page by the same rules. P70, P99, the average and the trimmed + average stay in the JSON (P99 over a few dozen samples is only the slowest one again). Seconds read with one + decimal and no redundant ".0" (`3s`, `4.6s`). +- **A test case table**, newest first: the latest run's result, a **Since previous run** column, then the earlier runs + going back in time (click any result to open the drawer on that run). The change column reads *Regressed* (passed in + the previous run, not in the latest), *Fixed*, *Flaky* (flipped between pass and fail more than once), *New* or + *Removed*. With several runs the table opens on the regressions. Two time columns + keep the populations apart: **Response time** is the median of the test case's assistant responses, and **Time per + attempt** is the mean wall-clock of a whole attempt (the responses plus the judge and, for conversations, the + simulated user). With more than two runs shown they follow the latest run only. Hover a value to see every sample + behind it; the drawer labels its two latency chips the same way. A report written before per-trace timing has no + response figure and shows `avg_latency_s` as the attempt figure. +- **An expression filter**, folded under *Advanced: filter by a check expression* in the Test cases section, for + cross-cutting questions the fixed filters can't anticipate, e.g. `d.filter_ranking_score === false` or `d.expected_metric_uris.length > 1 && !d.metrics_correct`. Available variables: - `d` (the run's `detail`), `it` (its item), `i` (the row, `i.per[label]` for any - run), `q` (question), `kind`. -- **The conversation**, when the item ran the agentic multi-turn path — every turn in + `d` (the checks of the run being read, its `detail`), `it` (that run's result), `i` (the row, `i.per[label]` for any + run), `q` (question), `kind` (category id). +- **The conversation**, when the test case ran the agentic multi-turn path — every turn in order, with the simulated user marked apart from a real question, so you can see whether the agent got there or was handed the answer. -- **A per-item drawer** — checks as pass/fail chips, expected vs actual side by side, +- **A per-test-case drawer** — checks as pass/fail chips, expected vs actual side by side, full reasoning, conversation/response ids. - **A latency timeline** from `detail.latency_breakdown`, in execution order, one bar per step. Clicking a step expands its full record, joined by `index`: the paragraph a reasoning step was summarised from, or a tool call's arguments and result from `detail.tool_calls`. +- **A print stylesheet** that drops the filters and the drawer, so "Save as PDF" gives a clean page. + +A report that merges several files names each run after its file, and a file named for its date (`2026-09-28.json`) +reads as "Sep 28". The page assumes the runs are one model followed over time; `gd-eval run --html` over several +models puts different models in one report, which it still lays out as run 1 against run 2. `--redact` additionally drops `transcript` and `tool_calls`: the exchange shows that the simulated user is primed with the expected output, and a tool result carries diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index 0997ad4dd..eff51e642 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -3,8 +3,8 @@ - -gd-eval report + +GoodData.AI evaluation report -
- gd-eval - +
+
GoodData.AI · Evaluation report

@@ -188,7 +243,7 @@

- Filters + Filter the report @@ -196,21 +251,22 @@

-
-

Items

+

Test cases

These filters narrow the table only, inside whatever the filters at the top allow.

- + -
-
- expression vars: d detail of the run being read · it that run's item · i row (i.per[label]) · q question · kind — evaluated once per selected run; an item matches when any run does -
+
Advanced: filter by a check expression + +
+ expression vars: d checks of the run being read · it that run's result · i row (i.per[label]) · q question · kind category id. A test case matches when any shown run does. +
+
@@ -224,9 +280,10 @@

Items

const $ = (s) => document.querySelector(s); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); const num = (v, d = 2) => (typeof v === 'number' ? v.toFixed(d) : '–'); -const pct = (v) => (typeof v === 'number' ? (v * 100).toFixed(1) + '%' : '–'); +const pct = (v) => (typeof v === 'number' ? Math.round(v * 100) + '%' : '–'); // Seconds, two decimals under 10s and one above, so a 117.8s tail and a 0.84s median both read. -const secs = (v) => (typeof v === 'number' ? v.toFixed(v < 10 ? 2 : 1) + 's' : '–'); +// Seconds: one decimal (4.6s, 16.2s), without a redundant ".0" (3s, not 3.0s). +const secs = (v) => (typeof v === 'number' ? v.toFixed(1).replace(/\.0$/, '') + 's' : '–'); const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; function median(xs) { const s = [...xs].sort((a, b) => a - b), m = s.length >> 1; @@ -253,13 +310,94 @@

Items

// The verdict an item was decided on: gate_passed (pass^K under --gate power), else pass_at_k for older reports. const passedOf = (item) => !!(item.gate_passed ?? item.pass_at_k); const statusOf = (item) => !item ? 'none' : item.skipped ? 'skip' : item.error ? 'err' : passedOf(item) ? 'pass' : 'fail'; -const LBL = { pass: 'PASS', fail: 'FAIL', err: 'ERR', skip: 'SKIP', none: '–' }; +const LBL = { pass: 'PASS', fail: 'FAIL', err: 'ERROR', skip: 'SKIP', none: '–' }; +// Test categories in words; unknown ones fall back to the raw id made readable. +const KIND_NAMES = { agentic_visualization: 'Visualization', agentic_metric_skill: 'Metric creation', + agentic_alert_skill: 'Alert creation', agentic_general_question: 'General question', agentic_guardrail: 'Guardrail' }; +const kindName = (k) => KIND_NAMES[k] || String(k || '').replace(/^agentic_/, '').replace(/_skill$/, '').replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()); const chipHtml = (s) => `${LBL[s]}`; +// ---------- run names ---------- +// Runs are identified by name (ideally a date) alone: time order is carried by position, and colour is kept for +// meaning (pass / fail), not identity. A merged report names a run " · " and the page shows the part +// before the dot, so a file named for its date reads as "Sep 28". +// Dates read as people say them: "Oct 13", with the year only when it isn't the current one ("Dec 2, 2025"). +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +function humanDate(d) { + return `${MONTHS[d.getMonth()]} ${d.getDate()}` + (d.getFullYear() !== new Date().getFullYear() ? `, ${d.getFullYear()}` : ''); +} +// A run named by an ISO date (2026-09-28) is shown as "Sep 28"; any other name is shown as it is. +function runName(name) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(name); + return m ? humanDate(new Date(+m[1], +m[2] - 1, +m[3])) : name; +} +const roleOf = (l) => runName(l.includes(' · ') ? l.split(' · ')[0] : l); +const uniq = (xs) => [...new Set(xs.filter(Boolean))]; + // ---------- header ---------- -// The wordmark already says gd-eval, so the default title would only repeat it. -$('#title').textContent = !DATA.title || DATA.title === 'gd-eval report' ? 'Evaluation report' : DATA.title; -$('#meta').innerHTML = `${rows.length} item(s) · ${LABELS.length} run(s) · generated ${esc(DATA.generated_at)}` + +{ + const runsList = LABELS.map((l) => DATA.runs[l]); + const models = uniq(runsList.map((r) => r.model)); + const wss = uniq(runsList.map((r) => r.workspace_id)); + const subject = `${models.join(', ') || 'Model'}${wss.length ? ' on ' + wss.join(', ') : ''}`; + // An explicit --title wins. Otherwise a fixed template says what kind of report this is and what was tested; + // the result itself is in the Results cards, not in prose up here. + const title = DATA.title && DATA.title !== 'gd-eval report' + ? DATA.title + : `${LABELS.length > 2 ? 'Run history' : LABELS.length > 1 ? 'Run comparison' : 'Run results'}: ${subject}`; + $('#title').textContent = title; + document.title = title + ' · GoodData.AI evaluation'; +} +// Shown in the viewer's own time zone; the original UTC stamp is on hover. +const genDate = (() => { + const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::\d{2})?\s*(UTC|Z)?$/.exec(String(DATA.generated_at || '').trim()); + const d = m ? new Date(`${m[1]}T${m[2]}:00Z`) : null; + // "Oct 11, 23:47" in the viewer's own time zone, the same date style as the run names. + const z = (n) => String(n).padStart(2, '0'); + return d && !isNaN(d) ? `${humanDate(d)}, ${z(d.getHours())}:${z(d.getMinutes())}` : DATA.generated_at; +})(); +// Everything that should be identical across runs lives behind one "Run setup" tooltip. If any of it differs, +// a warning names what changed, because results from different setups are not directly comparable. +const SETUP = [ + ['Model', (l) => DATA.runs[l].model], + ['Provider', (l) => ((DATA.comparison || {})[l] || {}).provider_name], + ['Workspace', (l) => DATA.runs[l].workspace_id], + ['Dataset', (l) => uniq(Object.values(DATA.runs[l].items || {}).map((it) => it.dataset_name)).join(', ')], + ['Test cases', (l) => (DATA.runs[l].summary || {}).total], + ['Attempts per test case', (l) => uniq(Object.values(DATA.runs[l].items || {}).map((it) => it.runs)).join(', ')], + ['Pass rule', (l) => ({ any: 'passes if any attempt passes', power: 'passes only if every attempt passes' })[DATA.runs[l].gate] || DATA.runs[l].gate], +].map(([k, f]) => { + const vals = LABELS.map((l) => f(l)).map((v) => (v == null || v === '' ? '\u2013' : String(v))); + return { k, vals, same: uniq(vals).length <= 1 }; +}).filter((x) => x.vals.some((v) => v !== '\u2013')); // a field the report does not carry (redacted) is not listed +const setupDiff = SETUP.filter((x) => !x.same); +// Consecutive runs with the same value collapse into one range: "ws_a (Aug 31 – Sep 28), then ws_b (Oct 5)". +function setupRanges(x) { + const segs = []; + LABELS.forEach((l, i) => { + const last = segs[segs.length - 1]; + if (last && last.v === x.vals[i]) last.to = l; else segs.push({ v: x.vals[i], from: l, to: l }); + }); + return segs.map((g) => `${esc(g.v)} (${esc(roleOf(g.from))}${g.to !== g.from ? ` \u2013 ${esc(roleOf(g.to))}` : ''})`).join(', then '); +} +// A small "!" next to a number whose basis changed between runs; the tooltip says what changed. +const caution = (html) => `${html}`; +// Test cases present in a run (optionally of one format), among the rows the page covers. +const idsIn = (l, mode) => new Set(dashRows.filter((r) => r.per[l] && (!mode || r.mode === mode)).map((r) => r.id)); +// What changed in the set of test cases between two runs, as text; '' when the set is identical. +function suiteChange(a, b, mode) { + const A = idsIn(a, mode), B = idsIn(b, mode); + const added = [...B].filter((id) => !A.has(id)).length, removed = [...A].filter((id) => !B.has(id)).length; + if (!added && !removed) return ''; + return `Not the same test cases: ${A.size} in ${esc(roleOf(a))}, ${B.size} in ${esc(roleOf(b))}` + + ` (${[added && `${added} added`, removed && `${removed} removed`].filter(Boolean).join(', ')}).`; +} +const setupTip = 'Run setup ' + + `${LABELS.length > 1 ? 'Same in every run' : 'Setup of this run'}` + + (SETUP.filter((x) => x.same).map((x) => `${esc(x.k)}${esc(x.vals[0])}`).join('') || 'nothing') + + (setupDiff.length ? 'Differs between runs' + setupDiff.map((x) => `${esc(x.k)}${setupRanges(x)}`).join('') : '') + + ''; +$('#meta').innerHTML = `Generated ${esc(genDate)} \u00b7 ` + setupTip + (DATA.redacted ? 'REDACTED' : ''); // ---------- the figures behind the dashboard ---------- @@ -267,8 +405,8 @@

Items

// disagree with the console or the JSON. Once a filter narrows the selection those figures no longer describe it, // so they are recomputed from the selected items' own records by the rules EvalReport uses (core/runner.py). // A report from an older gd-eval has no by_mode and says so. -const MODES = [['single_shot', 'Single-shot'], ['multi_turn', 'Multi-turn']]; -const MODE_NAME = { single_shot: 'single-shot', multi_turn: 'multi-turn' }; +const MODES = [['single_shot', 'Single-question'], ['multi_turn', 'Conversation']]; +const MODE_NAME = { single_shot: 'single-question', multi_turn: 'conversation' }; const figures = {}; let runsShown = LABELS; // the runs the dashboard covers; narrowed by the Runs filter at the top @@ -329,88 +467,135 @@

Items

function modeOf(label, mode) { return ((figures[label] || {}).by_mode || {})[mode] || null; } const hasModes = LABELS.some((l) => reported(l).by_mode); -// ---------- summary cards ---------- -function renderSummary() { - $('#summary').innerHTML = '

Runs

' + runsShown.map((label) => { - const f = figures[label]; - const rate = f.total ? f.passed / f.total : 0; - const split = MODES.map(([m]) => [m, modeOf(label, m)]).filter(([, x]) => x && x.total) - .map(([m, x]) => `${MODE_NAME[m]} ${x.passed}/${x.total}`); - const trace = (modeOf(label, 'single_shot') || {}).traces_s; - return `
${esc(label)} -
${f.total ? pct(rate) : '–'}
-
${f.passed}/${f.total} passed · ${f.errored} err · ${f.skipped} skip
- ${split.length ? `
${split.join(' · ')} passed
` : ''} -
- ${trace ? `
single-shot per trace · P50 ${secs(trace.p50)} · P90 ${secs(trace.p90)} · P99 ${secs(trace.p99)}
` : ''}
`; - }).join('') + '
'; +// The run the headline cards compare the latest run with: the previous run, unless the reader clicked another point on +// a sparkline. A pick that a filter has since removed, or that has become the latest run itself, is ignored. +let cmpPick = null; +function compareBase() { + const last = runsShown[runsShown.length - 1]; + return cmpPick && cmpPick !== last && runsShown.includes(cmpPick) ? cmpPick : runsShown[runsShown.length - 2]; } -// ---------- comparison ---------- -function renderComparison() { - if (runsShown.length < 2) { $('#comparison').innerHTML = ''; return; } - // A selection with no items has no rate, quality or latency, rather than a 0% one. - const some = (l, text) => (figures[l].total ? text : '–'); - const cols = [ - ['Run', (l) => esc(l), ''], - ['Pass rate', (l) => some(l, pct(figures[l].pass_rate)), 'num'], - ['Passed', (l) => `${figures[l].passed}/${figures[l].total}`, 'num'], - ['Avg quality', (l) => some(l, pct(figures[l].avg_quality_score)), 'num'], - ['Avg latency / run', (l) => some(l, num(figures[l].avg_latency_s) + 's'), 'num'], - ['Total latency', (l) => some(l, num(figures[l].total_latency_s, 1) + 's'), 'num'], - ]; - // Wrapped like the item table: with several runs it is wider than a narrow window, and it - // must scroll inside its own box rather than clip. - $('#comparison').innerHTML = '

Comparison

' + - cols.map(([h, , c]) => ``).join('') + '' + - runsShown.map((l) => '' + cols.map(([, f, c]) => ``).join('') + '').join('') + - '
${h}
${f(l)}
'; +// Median wall-clock of one attempt at a test case, over the test cases the page covers. +function medTimeOf(l) { + const xs = dashRows.flatMap((r) => (r.per[l] || {}).run_latencies_s || []); + return xs.length ? median(xs) : null; +} + +// ---------- results: three headline cards ---------- +// The report opens with three figures for the latest run: pass rate, quality and time. With an earlier run there is +// a change line against it (the previous run unless the reader picks another), and with three or more runs a +// sparkline of every run (hover a point for its value, click it to compare the latest run with that one). The +// per-format split lives in the test-case table's filters. +function kpiCard({ title, get, fmt, higher, isSame, base, after = '' }) { + const pts = runsShown.map((l) => ({ l, v: get(l) })); + const lastI = pts.length - 1, latest = pts[lastI]; + const b = base ? pts.find((p) => p.l === base) : null; + let change = ''; + if (latest.v != null && b && b.v != null) { + const d = latest.v - b.v; + // A chevron shows which way the value moved; its colour says whether that is good (green) or bad (red). + const good = (d > 0) === higher; + // Plain wording: where the value came from, not a difference in points. "▼ from 86% on Sep 28". + change = isSame(d, b.v) ? `same as ${fmt(b.v)} on ${esc(roleOf(b.l))}` + : `${good ? 'Better: ' : 'Worse: '}` + + `${CHEVRON(d > 0 ? 'up' : 'down')}${d > 0 ? 'up' : 'down'} from ${fmt(b.v)} on ${esc(roleOf(b.l))}` + after; + } + let spark = ''; + const seq = pts.map((p, i) => [p, i]).filter(([p]) => p.v != null); + if (pts.length >= 3 && seq.length >= 2) { + const W = 220, H = 44, P = 6, vals = seq.map(([p]) => p.v); + let lo = Math.min(...vals), hi = Math.max(...vals); + if (hi - lo < 1e-9) { lo -= 1; hi += 1; } + const x = (i) => P + (i * (W - 2 * P)) / lastI, y = (v) => P + (1 - (v - lo) / (hi - lo)) * (H - 2 * P); + const step = (W - 2 * P) / lastI; + spark = ` + ` + + seq.map(([p, i]) => ``).join('') + + pts.map((p, i) => `${esc(roleOf(p.l))}: ${p.v == null ? '\u2013' : fmt(p.v)}${i === lastI ? ' (latest)' : '. Click to compare with it.'}`).join('') + + `
${esc(roleOf(pts[0].l))}${esc(roleOf(latest.l))}
`; + } + return `
${title}
+
${latest.v == null ? '\u2013' : fmt(latest.v)}
+ ${change ? `
${change}
` : ''}${spark}
`; +} +function renderSummary() { + const base = runsShown.length > 1 ? compareBase() : null; + $('#summary').innerHTML = '

Results

' + + kpiCard({ title: 'Test cases passed', get: (l) => (figures[l].total ? figures[l].passed / figures[l].total : null), fmt: pct, higher: true, + isSame: (d) => Math.round(d * 100) === 0, base, after: ' which?' }) + + kpiCard({ title: 'Average quality score', get: (l) => (figures[l].total ? figures[l].avg_quality_score : null), fmt: pct, higher: true, + isSame: (d) => Math.abs(d) < 0.01, base }) + + kpiCard({ title: 'Median time per attempt', get: medTimeOf, fmt: secs, higher: false, + isSame: (d, b) => Math.abs(d) < Math.max(0.5, 0.05 * b), base }) + + '
'; +} +function sparkHover(run) { + $('#summary').querySelectorAll('circle[data-run]').forEach((el) => el.classList.toggle('on', el.dataset.run === run)); } +function sparkPick(run) { + cmpPick = run; + renderSummary(); +} +$('#summary').addEventListener('mouseover', (e) => { const h = e.target.closest('.hit'); if (h) sparkHover(h.dataset.run); }); +$('#summary').addEventListener('focusin', (e) => { const h = e.target.closest('.hit'); if (h) sparkHover(h.dataset.run); }); +$('#summary').addEventListener('mouseleave', () => sparkHover(null)); +$('#summary').addEventListener('click', (e) => { const h = e.target.closest('.hit'); if (h) sparkPick(h.dataset.run); }); +$('#summary').addEventListener('keydown', (e) => { + const h = e.target.closest('.hit'); + if (h && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); sparkPick(h.dataset.run); } +}); // ---------- latency by mode ---------- // Per trace is the main figure. gen-ai opens one trace per chat message, so a single-shot run is exactly one // trace and its latency is that trace's response time. A multi-turn conversation spans several traces, so it // is shown both ways: per trace (each turn, comparable with single-shot) and per full run (the whole // conversation, simulated-user replies and judge included). -function stripHtml(st) { - const span = st.max - st.min; - const at = (v) => (span > 0 ? ((v - st.min) / span) * 100 : 50); - const ticks = [['50', st.p50], ['70', st.p70], ['90', st.p90], ['99', st.p99]]; - return `'; -} const HEAD_HELP = { - Avg: 'Mean of every sample.', - 'Trimmed avg': 'Mean of the middle 80%: the fastest 10% and the slowest 10% of samples are dropped first. Needs at least 10 samples.', - 'P50 (median)': 'The median: half the samples are faster, half slower.', + Median: 'Half were faster than this, half slower (P50).', + P90: '90% finished within this time.', }; -function latencyTable({ mode, field, sample, counts }) { - const heads = ['Run'].concat(counts ? ['Items', 'Passed'] : []) - .concat([sample, 'Avg', 'Trimmed avg', 'P50 (median)', 'P70', 'P90', 'P99', 'Max', 'Range']); - const dash = '–'; - const rows = runsShown.map((l) => { - const m = modeOf(l, mode); - const st = m ? m[field] : null; - const lead = counts - ? (m && m.total ? `${m.total}${m.passed}/${m.total} (${pct(m.passed / m.total)})` : `0${dash}`) - : ''; - const stats = st - ? [st.n, secs(st.mean), secs(st.trimmed_mean), secs(st.p50), secs(st.p70), secs(st.p90), secs(st.p99), secs(st.max)] - .map((v) => `${v}`).join('') + `${stripHtml(st)}` - : dash.repeat(8) + ''; - return `${esc(l)}${lead}${stats}`; - }).join(''); - const small = runsShown.some((l) => { const m = modeOf(l, mode); return m && m[field] && m[field].n < 100; }); - const untrimmed = runsShown.some((l) => { const m = modeOf(l, mode); return m && m[field] && m[field].trimmed_mean == null; }); +// Colour rules for the whole report: green = better / quickest, red = worse / slowest, lime = the latest run (and the +// row open in the side panel). Direction is always a chevron, up or down, so colour is never the only signal. +const CHEVRON = (dir) => ``; +// Marks the latest run wherever runs are listed. +const LATEST_PILL = ' Latest'; +function latencyTable({ mode, field, sample }) { + // Plain figures only. P99 is left out: with a few dozen + // samples it is just the slowest one again. + const heads = ['Run', 'Fastest', 'Median', 'P90', 'Slowest']; const unit = sample.toLowerCase(); - const notes = []; - if (small) notes.push(`With fewer than 100 ${unit}, P99 is effectively the slowest one.`); - if (untrimmed) notes.push('Trimmed avg needs at least 10 samples.'); + const dash = '\u2013'; + // In each column, the quickest run is marked lime and the slowest red, so the best and worst runs stand out at a + // glance. Compared on the rounded figure the reader sees, so ties are marked alike; nothing is marked when every + // run shows the same value or there is only one run. + const KEYS = ['min', 'p50', 'p90', 'max']; + // Icons carry the meaning as well as the colour: down = least time (quickest), up = most time (slowest). + const EXT_ICON = { best: CHEVRON('down'), worst: CHEVRON('up') }; + const statsOf = (l) => (modeOf(l, mode) || {})[field] || null; + const ext = KEYS.map((k) => { + const vs = runsShown.map((l) => statsOf(l)).filter(Boolean).map((st) => +st[k].toFixed(1)); + return vs.length > 1 && Math.min(...vs) !== Math.max(...vs) ? { lo: Math.min(...vs), hi: Math.max(...vs) } : null; + }); + const mark = (j, v) => (!ext[j] ? '' : +v.toFixed(1) === ext[j].lo ? 'best' : +v.toFixed(1) === ext[j].hi ? 'worst' : ''); + const rows = runsShown.map((l, i) => { + const st = statsOf(l); + const prevL = runsShown[i - 1], prev = prevL ? ((modeOf(prevL, mode) || {})[field] || null) : null; + const basis = st ? `Based on ${st.n} ${unit}` : ''; + const stats = st ? KEYS.map((k, j) => { + const m = mark(j, st[k]), txt = secs(st[k]); + const why = m === 'best' ? ' \u00b7 quickest of the runs shown' : m === 'worst' ? ' \u00b7 slowest of the runs shown' : ''; + return `${m ? `${EXT_ICON[m]}${txt}` : txt}`; + }).join('') : dash.repeat(4); + const flag = st && prev && prev.n !== st.n + ? ' ' + caution(`${st.n} ${unit} timed here, ${prev.n} in ${esc(roleOf(prevL))}.` + (suiteChange(prevL, l, mode) ? '
' + suiteChange(prevL, l, mode) : '')) + : ''; + const isLatest = runsShown.length > 1 && i === runsShown.length - 1; + return `${esc(roleOf(l))}${isLatest ? LATEST_PILL : ''}${flag}${stats}`; + }).reverse().join(''); // newest run first return `
` + - heads.map((h, i) => ``).join('') + `${rows}
${h}
` + - (notes.length ? `
${notes.join(' ')}
` : ''); + heads.map((h, i) => `${h}`).join('') + + `${rows}
` + + (ext.some(Boolean) ? `

${EXT_ICON.best}quickest run ${EXT_ICON.worst}slowest run in each column

` : ''); } function modeSection(mode, title, blurb, tables) { if (!runsShown.some((l) => (modeOf(l, mode) || {}).total)) return ''; @@ -420,13 +605,13 @@

Items

function renderLatency() { $('#latency').innerHTML = !hasModes ? '

Latency

This report was written before gd-eval split latency into single-shot and multi-turn. Re-run to get per-trace percentiles.

' - : modeSection('single_shot', 'Single-shot · latency per trace', - 'One chat turn per run, so each run is exactly one trace. Latency is that trace\'s response time, from sending the message to the end of its stream; the judge, the simulated user and conversation set-up are not in it.', - [['', { field: 'traces_s', sample: 'Traces', counts: true }]]) + - modeSection('multi_turn', 'Multi-turn · conversations', - 'A conversation spans several traces, so it is shown both ways: per trace (each chat turn, comparable with single-shot) and per full run (the whole conversation, with simulated-user replies and the judge).', - [['Per trace · each chat turn', { field: 'traces_s', sample: 'Traces', counts: true }], - ['Per full run · whole conversation', { field: 'runs_s', sample: 'Runs', counts: false }]]); + : modeSection('single_shot', 'Single-question tests · response time', + 'The assistant is asked one question and answers once, with no follow-up. Each answer is one response (one assistant reply, as recorded in tracing); its time runs from sending the question to the end of the streamed answer. Grading and test set-up are not included.', + [['', { field: 'traces_s', sample: 'Responses' }]]) + + modeSection('multi_turn', 'Conversation tests · response time', + 'The assistant holds a back-and-forth with a simulated user over several messages. Time is shown two ways: per response (each assistant reply, comparable with single-question tests) and per conversation (start to finish, including the simulated user\'s replies and grading).', + [['Per response · each assistant reply', { field: 'traces_s', sample: 'Responses' }], + ['Per conversation · start to finish', { field: 'runs_s', sample: 'Conversations' }]]); } // ---------- per-item latency ---------- @@ -445,8 +630,8 @@

Items

const shown = xs.slice(0, 12).map(secs).join(', '); return `${xs.length} ${noun}${xs.length === 1 ? '' : 's'}: ${shown}${xs.length > 12 ? ', …' : ''}`; } -const TRACE_HELP = 'Median response time of this item\'s chat turns, one gen-ai trace each. Excludes the judge, the ' + - 'simulated user and conversation set-up. Hover a value to see every trace.'; +const TRACE_HELP = 'Median time of this test case\'s assistant responses (one per message). Excludes grading, the ' + + 'simulated user and set-up. Hover a value to see every response.'; const RUN_HELP = 'Mean wall-clock of a whole run: the chat turns plus the judge, the simulated user\'s replies and ' + 'any conversation set-up. Hover a value to see every run.'; const latencyCell = (v, title) => `${secs(v)}`; @@ -457,15 +642,15 @@

Items

// a
holding one checkbox per option, so they need no widget library and stay keyboard-operable. Nothing // ticked means everything. const STATUS_OPTIONS = [ - { value: 'pass', text: 'passed' }, { value: 'fail', text: 'failed' }, - { value: 'err', text: 'errored' }, { value: 'skip', text: 'skipped' }, + { value: 'pass', text: 'Passed' }, { value: 'fail', text: 'Failed' }, + { value: 'err', text: 'Could not be graded' }, { value: 'skip', text: 'Skipped' }, ]; function controlsHtml(p, runsTitle) { const multi = (id, title) => `
`; - const modes = [['', 'any mode'], ['single_shot', 'single-shot'], ['multi_turn', 'multi-turn']]; - return multi('kind', 'Kind; choose as many as you like') + - `` + + const modes = [['', 'Any format'], ['single_shot', 'Single question'], ['multi_turn', 'Conversation']]; + return multi('kind', 'Category; choose as many as you like') + + `` + multi('status', 'Status; choose as many as you like. With several runs an item matches when it has that status in any of them') + multi('runs', runsTitle); } @@ -473,13 +658,29 @@

Items

$('#items-controls').innerHTML = controlsHtml('items', 'Runs; choose which ones the table shows and reads status and the expression from'); -function makeMulti(el, noun, anyText, onChange) { +// GoodData attribute-filter composition: the button reads "Title: selection"; the popup has a search field, an +// All row, the values (each with an Only shortcut and its count), and Cancel / Apply. Changes are staged in the +// popup and take effect only on Apply; closing it any other way discards them. +const FILTER_TITLE = { kinds: 'Category', statuses: 'Result', runs: 'Run' }; +function makeMulti(el, noun, onChange) { const summary = el.querySelector('summary'), menu = el.querySelector('.menu'); const m = { el, sel: new Set(), opts: [], sig: null }; + const title = FILTER_TITLE[noun] || noun; + let dAll = true, dSel = new Set(); // the draft: All, or the ticked values const refresh = () => { const picked = m.opts.filter((o) => m.sel.has(o.value)); - summary.textContent = picked.length === 0 ? anyText : picked.length === 1 ? picked[0].text : `${picked.length} ${noun}`; + const val = picked.length === 0 ? 'All' : picked.length === 1 ? picked[0].text : `${picked.length} selected`; + summary.innerHTML = `${esc(title)}: ${esc(val)}`; + summary.title = picked.length > 1 ? picked.map((o) => o.text).join(', ') : ''; + }; + const sync = () => { + menu.querySelectorAll('.opt input').forEach((b) => { b.checked = dAll || dSel.has(b.value); }); + const all = menu.querySelector('.allrow input'); + if (all) { all.checked = dAll; all.indeterminate = !dAll && dSel.size > 0; } + const ap = menu.querySelector('.apply'); + if (ap) ap.disabled = !dAll && dSel.size === 0; }; + const loadDraft = () => { dAll = m.sel.size === 0; dSel = new Set(m.sel); sync(); }; // Options can change under a selection (the table's kinds follow the top filters): a ticked value that is no // longer offered is dropped, so what the summary says is what is applied. The menu is only rebuilt when the // offered values change, so a checkbox keeps focus while it is being used. @@ -489,30 +690,66 @@

Items

const sig = opts.map((o) => o.value).join('\n'); if (sig !== m.sig) { m.sig = sig; - menu.innerHTML = '' + opts.map((o) => - ``).join(''); + menu.innerHTML = `` + + '
' + + opts.map((o) => ``).join('') + + '
'; + loadDraft(); } - menu.querySelectorAll('label').forEach((label, n) => { + menu.querySelectorAll('.opt').forEach((label, n) => { const c = opts[n].count; label.querySelector('i').textContent = c == null ? '' : c; label.classList.toggle('zero', c === 0); }); refresh(); }; - m.clear = () => { + m.clear = () => { m.sel.clear(); loadDraft(); refresh(); }; + const apply = () => { m.sel.clear(); - menu.querySelectorAll('input').forEach((i) => { i.checked = false; }); + if (!dAll && dSel.size < m.opts.length) dSel.forEach((v) => m.sel.add(v)); refresh(); + el.open = false; + summary.focus(); + onChange(); }; menu.addEventListener('input', (e) => { - if (!e.target.matches('input')) return; - if (e.target.checked) m.sel.add(e.target.value); else m.sel.delete(e.target.value); - refresh(); - onChange(); + const t = e.target; + if (t.matches('.search')) { + const q = t.value.trim().toLowerCase(); + menu.querySelectorAll('.opt').forEach((l) => { l.hidden = !!q && !l.querySelector('span').textContent.toLowerCase().includes(q); }); + return; + } + if (!t.matches('input[type=checkbox]')) return; + if (t.closest('.allrow')) { dAll = t.checked; dSel = new Set(); } + else { + if (dAll) { dAll = false; dSel = new Set(m.opts.map((o) => o.value)); } + if (t.checked) dSel.add(t.value); else dSel.delete(t.value); + if (dSel.size === m.opts.length) { dAll = true; dSel = new Set(); } + } + sync(); + }); + menu.addEventListener('click', (e) => { + const b = e.target.closest('button'); + if (!b) return; + if (b.matches('.only')) { e.preventDefault(); dAll = false; dSel = new Set([b.dataset.v]); sync(); } + else if (b.matches('.apply')) apply(); + else if (b.matches('.cancel')) { el.open = false; summary.focus(); } + }); + menu.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.target.matches('.search, input[type=checkbox]')) { + e.preventDefault(); + const ap = menu.querySelector('.apply'); + if (ap && !ap.disabled) apply(); + } }); - menu.addEventListener('click', (e) => { if (e.target.matches('.clear')) { m.clear(); onChange(); } }); el.addEventListener('toggle', () => { - if (el.open) document.querySelectorAll('.multi[open]').forEach((o) => { if (o !== el) o.open = false; }); + if (!el.open) return; + document.querySelectorAll('.multi[open]').forEach((o) => { if (o !== el) o.open = false; }); + const s = menu.querySelector('.search'); + if (s) { s.value = ''; menu.querySelectorAll('.opt').forEach((l) => { l.hidden = false; }); } + loadDraft(); + if (s) s.focus(); }); return m; } @@ -525,27 +762,27 @@

Items

}); const T = { // the filters at the top: they decide what the whole page covers - kind: makeMulti($('#top-kind'), 'kinds', 'any kind', render), + kind: makeMulti($('#top-kind'), 'kinds', render), mode: $('#top-mode'), - status: makeMulti($('#top-status'), 'statuses', 'any status', render), - runs: makeMulti($('#top-runs'), 'runs', 'all runs', render), + status: makeMulti($('#top-status'), 'statuses', render), + runs: makeMulti($('#top-runs'), 'runs', render), }; const I = { // the filters in the Items section: the table only - kind: makeMulti($('#items-kind'), 'kinds', 'any kind', renderItems), + kind: makeMulti($('#items-kind'), 'kinds', renderItems), mode: $('#items-mode'), - status: makeMulti($('#items-status'), 'statuses', 'any status', renderItems), - runs: makeMulti($('#items-runs'), 'runs', 'all runs', renderItems), + status: makeMulti($('#items-status'), 'statuses', renderItems), + runs: makeMulti($('#items-runs'), 'runs', renderItems), }; // With one run there is nothing to choose between. $('#top-runs').hidden = $('#items-runs').hidden = LABELS.length < 2; const kinds = [...new Set(rows.map((r) => r.kind))].filter(Boolean).sort(); const countBy = (list) => { const c = {}; for (const r of list) c[r.kind] = (c[r.kind] || 0) + 1; return c; }; -T.kind.set(kinds.map((k) => ({ value: k, text: k, count: countBy(rows)[k] }))); +T.kind.set(kinds.map((k) => ({ value: k, text: kindName(k), count: countBy(rows)[k] }))); T.status.set(STATUS_OPTIONS); -T.runs.set(LABELS.map((l) => ({ value: l, text: l }))); +T.runs.set(LABELS.map((l) => ({ value: l, text: roleOf(l) }))); I.status.set(STATUS_OPTIONS); -let sortKey = 'id', sortDir = 1, selected = null, drawerRun = null, drawerSig = ''; +let sortKey = LABELS.length > 1 ? 'chg' : 's:' + LABELS[0], sortDir = 1, selected = null, drawerRun = null, drawerSig = ''; function exprFn() { const src = $('#expr').value.trim(); @@ -597,6 +834,7 @@

Items

} function activeSortKey(runList) { const c = sortKey.indexOf(':'); + if (sortKey === 'chg') return runList.length > 1 ? 'chg' : 'id'; return c < 0 || runList.includes(sortKey.slice(c + 1)) ? sortKey : 'id'; } function sortRows(list, key) { @@ -605,7 +843,27 @@

Items

return (va < vb ? -1 : va > vb ? 1 : 0) * sortDir; }); } +// How a test case moved between the last two runs shown, and whether it has flipped back and forth across all of them. +const CHANGE_ORDER = { Regressed: 0, New: 1, Removed: 2, Flaky: 3, 'Still failing': 4, Fixed: 5, 'Still passing': 6, '': 7 }; +function changeOf(r, runList) { + if (runList.length > 1) { + const prevIt = r.per[runList[runList.length - 2]], curIt = r.per[runList[runList.length - 1]]; + if (curIt && !prevIt) return 'New'; + if (prevIt && !curIt) return 'Removed'; + } + const st = runList.map((l) => statusOf(r.per[l])).filter((x) => x !== 'none' && x !== 'skip'); + if (st.length < 2) return ''; + const ok = st.map((x) => x === 'pass'); + const flips = ok.slice(1).filter((v, i) => v !== ok[i]).length; + const [p, q] = ok.slice(-2); + if (p && !q) return 'Regressed'; + if (!p && q) return flips > 1 ? 'Flaky' : 'Fixed'; + if (flips > 1) return 'Flaky'; + return q ? 'Still passing' : 'Still failing'; +} +const CHANGE_CLASS = { Regressed: 'fail', Fixed: 'pass', Flaky: 'err', New: 'err', Removed: 'err', 'Still failing': 'none', 'Still passing': 'none' }; function sortVal(r, key) { + if (key === 'chg') return CHANGE_ORDER[changeOf(r, itemRuns())]; const c = key.indexOf(':'); if (c < 0) return r[key] || ''; // id, kind, mode, question const tag = key.slice(0, c), it = r.per[key.slice(c + 1)]; @@ -618,28 +876,28 @@

Items

const itemsActive = () => !!($('#q').value.trim() || $('#expr').value.trim() || I.kind.sel.size || I.mode.value || I.status.sel.size || I.runs.sel.size); function renderScope() { - $('#top-count').textContent = `${dashRows.length} of ${rows.length} items`; + $('#top-count').textContent = dashRows.length !== rows.length ? `Showing ${dashRows.length} of ${rows.length} test cases` : ''; $('#top-reset').hidden = !topActive(); const parts = []; - if (dashRows.length !== rows.length) parts.push(`the Runs, Comparison, Latency and Items sections cover these ${dashRows.length} of ${rows.length} items`); + if (dashRows.length !== rows.length) parts.push(`every section covers ${dashRows.length} of the ${rows.length} test cases`); if (runsShown.length !== LABELS.length) parts.push(`showing ${runsShown.length} of ${LABELS.length} runs`); const several = runsShown.length > 1 && T.status.sel.size; // Ticking every run is the same as ticking none: say nothing unless the selection really is narrower. $('#scope').hidden = parts.length === 0; $('#scope').innerHTML = parts.length - ? `Filtered: ${parts.join('; ')}.` + (several ? ' With several runs, an item matches a status when any of the runs shown has it.' : '') + ? `Filtered: ${parts.join('; ')}.` + (several ? ' With several runs, a test case matches a result when any of the runs shown has it.' : '') : ''; } // The table's own options follow the top selection: only kinds that survive it are offered (with how many items // each has there), only the runs on show, and only the modes still present. function syncItemControls() { const byKind = countBy(dashRows); - I.kind.set(Object.keys(byKind).filter(Boolean).sort().map((k) => ({ value: k, text: k, count: byKind[k] }))); - I.runs.set(runsShown.map((l) => ({ value: l, text: l }))); + I.kind.set(Object.keys(byKind).filter(Boolean).sort().map((k) => ({ value: k, text: kindName(k), count: byKind[k] }))); + I.runs.set(runsShown.map((l) => ({ value: l, text: roleOf(l) }))); const present = new Set(dashRows.map((r) => r.mode)); const keep = I.mode.value; - I.mode.innerHTML = '' + MODES.filter(([m]) => present.has(m)) - .map(([m]) => ``).join(''); + I.mode.innerHTML = '' + MODES.filter(([m]) => present.has(m)) + .map(([m, name]) => ``).join(''); I.mode.value = present.has(keep) ? keep : ''; } // Everything on the page follows the top filters. Sorting, the table's filters and selecting a row only touch the @@ -653,7 +911,6 @@

Items

syncItemControls(); renderScope(); renderSummary(); - renderComparison(); renderLatency(); renderItems(); } @@ -661,26 +918,33 @@

Items

const runList = itemRuns(); const list = sortRows(itemRows(runList), activeSortKey(runList)); const key = activeSortKey(runList), multi = runList.length > 1; - $('#items-count').textContent = `${list.length} of ${dashRows.length} items`; + $('#items-count').textContent = list.length !== dashRows.length ? `Showing ${list.length} of ${dashRows.length}` : ''; $('#items-reset').hidden = !itemsActive(); - const runHdr = (l) => (multi ? `${esc(l)}` : ''); + const timeRuns = runList.length > 2 ? runList.slice(-1) : runList; + const runHdr = (l) => (multi ? `${runList.length > 2 ? 'latest run' : esc(roleOf(l))}` : ''); const heads = [ - { k: 'id', html: 'Item' }, { k: 'kind', html: 'Kind' }, { k: 'mode', html: 'Mode' }, { k: 'question', html: 'Question' }, - ...runList.map((l) => ({ k: 's:' + l, html: esc(l) })), - ...runList.map((l) => ({ k: 'lt:' + l, html: 'Latency / trace' + runHdr(l), help: TRACE_HELP, num: true })), - ...runList.map((l) => ({ k: 'lr:' + l, html: 'Latency / run' + runHdr(l), help: RUN_HELP, num: true })), + { k: 'id', html: 'Test case' }, { k: 'kind', html: 'Category' }, { k: 'mode', html: 'Format' }, { k: 'question', html: 'Question' }, + // Newest first: the latest result, what changed since the previous run, then earlier runs going back in time. + ...(multi ? [{ k: 's:' + runList[runList.length - 1], html: esc(roleOf(runList[runList.length - 1])) + LATEST_PILL }] : []), + ...(multi ? [{ k: 'chg', html: 'Since previous run', help: 'Regressed: passed in the previous run, not in the latest. Fixed: the reverse. Flaky: has flipped between pass and fail more than once across the runs shown. New / Removed: the test case was added to or dropped from the suite.' }] : []), + ...(multi ? runList.slice(0, -1).reverse() : runList).map((l) => ({ k: 's:' + l, html: esc(roleOf(l)) })), + ...timeRuns.map((l) => ({ k: 'lt:' + l, html: 'Response time' + runHdr(l), help: TRACE_HELP, num: true })), + ...timeRuns.map((l) => ({ k: 'lr:' + l, html: 'Time per attempt' + runHdr(l), help: RUN_HELP, num: true })), ]; $('#items thead').innerHTML = '' + heads.map((h) => `${h.html}${key === h.k ? (sortDir > 0 ? ' ▲' : ' ▼') : ''}`).join('') + ''; + const stCell = (r) => (l) => `${chipHtml(statusOf(r.per[l]))}`; $('#items tbody').innerHTML = list.map((r) => ` ${esc(r.id)} - ${esc(r.kind)} + ${esc(kindName(r.kind))} ${r.mode ? `${esc(MODE_NAME[r.mode] || r.mode)}` : ''} ${esc(r.question)}` + - runList.map((l) => `${chipHtml(statusOf(r.per[l]))}`).join('') + - runList.map((l) => latencyCell(traceLatency(r.per[l]), samplesTitle((r.per[l] || {}).trace_latencies_s, 'trace'))).join('') + - runList.map((l) => latencyCell(runLatency(r.per[l]), samplesTitle((r.per[l] || {}).run_latencies_s, 'run'))).join('') + + (multi ? [runList[runList.length - 1]] : []).map(stCell(r)).join('') + + (multi ? (() => { const c = changeOf(r, runList); return `${c ? `${c}` : ''}`; })() : '') + + (multi ? runList.slice(0, -1).reverse() : runList).map(stCell(r)).join('') + + timeRuns.map((l) => latencyCell(traceLatency(r.per[l]), samplesTitle((r.per[l] || {}).trace_latencies_s, 'response'))).join('') + + timeRuns.map((l) => latencyCell(runLatency(r.per[l]), samplesTitle((r.per[l] || {}).run_latencies_s, 'attempt'))).join('') + '').join(''); $('#noitems').hidden = list.length > 0; // The drawer follows the table's runs: if the run it shows was filtered out, move it to one that is still there. @@ -716,6 +980,8 @@

Items

}); $('#items tbody').addEventListener('click', (e) => { const id = e.target.closest('tr')?.dataset.id; + const run = e.target.closest('.stcell')?.dataset.run; + if (id && run) { drawerRun = run; selected = id; renderItems(); openDrawer(byId.get(id)); return; } if (id) openRow(id); }); $('#items tbody').addEventListener('keydown', (e) => { @@ -743,8 +1009,8 @@

Items

const traces = it.trace_latencies_s || []; const out = []; if (traces.length) { - out.push(chip(`${traces.length} trace${traces.length === 1 ? '' : 's'} · median ${secs(median(traces))}`, - `Response time of each chat turn. ${samplesTitle(traces, 'trace')}`)); + out.push(chip(`${traces.length} response${traces.length === 1 ? '' : 's'} · median ${secs(median(traces))}`, + `Time of each assistant response. ${samplesTitle(traces, 'response')}`)); } if (typeof it.best_run_latency_s === 'number') { out.push(chip(`run ${secs(it.best_run_latency_s)}`, @@ -764,7 +1030,7 @@

Items

// With several runs in the table the drawer says which one it shows and lets you switch. const heading = runList.length > 1 ? `` + ``).join('')}` : `

${esc(run)}

`; const wire = () => { d.querySelector('.close').onclick = () => { d.classList.remove('open'); selected = null; renderItems(); }; @@ -782,7 +1048,7 @@

Items

${esc(r.id)}

${esc(r.question)}

${chipHtml(st)} - ${esc(r.kind)} + ${esc(kindName(r.kind))} ${it.runs ?? 0} run(s) ${drawerLatencyChips(it)} ${it.classification ? `${esc(it.classification)}` : ''} @@ -911,11 +1177,11 @@

Items

return `
${esc(s.name)}
-
${num(s.duration_s)}s
`; +
${secs(s.duration_s)}
`; }).join(''); return `

Latency timeline

${rows}
-
${tl.length} step(s), ${num(sum)}s accounted` + - (gap != null ? ` of ${num(total)}s (${num(gap)}s setup/teardown)` : '') + +
${tl.length} step(s), ${secs(sum)} accounted` + + (gap != null ? ` of ${secs(total)} (${secs(gap)} setup/teardown)` : '') + `
`; } @@ -932,6 +1198,48 @@

Items

} render(); + +// ---------- tooltips as a page overlay ---------- +// Every .info element keeps its tooltip content in a hidden .tip child. On hover or keyboard focus that content is +// shown in one fixed-position box on top of the page, placed below the trigger (above it near the bottom edge) and +// kept inside the window. It stays open while the pointer moves onto it, and Esc closes it (WCAG 1.4.13). +const floatTip = document.createElement('div'); +floatTip.className = 'floattip'; floatTip.id = 'floattip'; floatTip.setAttribute('role', 'tooltip'); floatTip.hidden = true; +document.body.appendChild(floatTip); +let tipAnchor = null, tipTimer = null; +function showTip(el) { + const src = el.querySelector('.tip'); + if (!src) return; + clearTimeout(tipTimer); + if (tipAnchor && tipAnchor !== el) tipAnchor.removeAttribute('aria-describedby'); + tipAnchor = el; + floatTip.innerHTML = src.innerHTML; + floatTip.hidden = false; + el.setAttribute('aria-describedby', 'floattip'); + const r = el.getBoundingClientRect(), tw = floatTip.offsetWidth, th = floatTip.offsetHeight, m = 8; + const left = Math.min(Math.max(m, r.left + r.width / 2 - tw / 2), window.innerWidth - tw - m); + let top = r.bottom + 6; + if (top + th > window.innerHeight - m) top = Math.max(m, r.top - th - 6); + floatTip.style.left = `${left}px`; floatTip.style.top = `${top}px`; +} +function hideTip() { + clearTimeout(tipTimer); + floatTip.hidden = true; + if (tipAnchor) tipAnchor.removeAttribute('aria-describedby'); + tipAnchor = null; +} +const hideTipSoon = () => { clearTimeout(tipTimer); tipTimer = setTimeout(hideTip, 150); }; +document.addEventListener('mouseover', (e) => { + const el = e.target.closest('.info'); + if (el) showTip(el); + else if (floatTip.contains(e.target)) clearTimeout(tipTimer); + else if (tipAnchor && document.activeElement !== tipAnchor) hideTipSoon(); +}); +document.addEventListener('focusin', (e) => { const el = e.target.closest('.info'); if (el) showTip(el); }); +document.addEventListener('focusout', (e) => { if (e.target.closest('.info')) hideTip(); }); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !floatTip.hidden) hideTip(); }); +window.addEventListener('scroll', () => { if (!floatTip.hidden) hideTip(); }, true); +window.addEventListener('resize', () => { if (!floatTip.hidden) hideTip(); }); diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index 73f481f45..9ed75073e 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -237,16 +237,21 @@ def test_redact_keeps_the_mode_split_and_latency(): assert data["runs"]["Model A"]["items"]["item-1"]["trace_latencies_s"] == [1.0, 2.0] -def test_the_page_has_single_shot_and_multi_turn_sections_read_from_the_report(): +def test_the_page_has_single_question_and_conversation_sections_read_from_the_report(): """The report is a view over the JSON: the page names the sections and populations and reads the figures from `by_mode`. It holds no percentile arithmetic that could disagree with the console or the JSON.""" html = build_html(_doc_with_modes("gpt-5")) assert "by_mode" in html - for text in ("Single-shot · latency per trace", "Multi-turn · conversations", "Per trace", "Per full run"): + for text in ( + "Single-question tests · response time", + "Conversation tests · response time", + "Per response", + "Per conversation", + ): assert text in html - for label in ("P50", "P70", "P90", "P99"): + for label in ("Fastest", "Median", "P90", "Slowest"): assert label in html # The table renderers only lay out figures they are handed; the one place that computes any is `recompute`. renderers = html.split("// ---------- latency by mode")[1].split("function renderLatency")[0] @@ -260,8 +265,8 @@ def test_the_item_table_names_what_each_latency_column_measures(): html = build_html(_doc_with_modes("gpt-5")) for text in ( - "Latency / trace", - "Latency / run", + "Response time", + "Time per attempt", "trace_latencies_s", "run_latencies_s", "'lt:' + l", @@ -275,11 +280,11 @@ def test_the_filters_sit_above_the_dashboard_and_drive_every_table(): html = build_html(_doc_with_modes("gpt-5")) bar = html.index('id="filterbar"') - for section in ('id="summary"', 'id="comparison"', 'id="latency"', 'id="items"'): + for section in ('id="summary"', 'id="latency"', 'id="items"'): assert bar < html.index(section), f"the filter bar must come before {section}" - # One render path feeds the cards, the comparison, the latency tables and the item table. + # One render path feeds the result cards, the latency tables and the item table. render = html.split("function render() {")[1].split("function renderItems")[0] - for call in ("renderSummary()", "renderComparison()", "renderLatency()", "renderItems()"): + for call in ("renderSummary()", "renderLatency()", "renderItems()"): assert call in render @@ -338,17 +343,27 @@ def test_the_tables_filters_combine_with_the_top_ones_without_widening_them(): assert "rows.filter(" not in items -def test_the_latency_tables_show_a_trimmed_average_beside_the_average_and_median(): - """The trimmed average is a different figure from the median (P50): it still averages, without the - fastest and slowest tenth. The page reads it from the report and recomputes it only for a filtered view.""" +def test_the_latency_tables_show_plain_figures_and_leave_the_long_tail_to_the_json(): + """The tables carry the fastest, the median, P90 and the slowest. P99 over a few dozen samples is just the + slowest one again, and the average and trimmed average stay in the JSON and the console. A filtered view + still recomputes the full distribution, so its `by_mode` has the shape the report's own would.""" html = build_html(_doc_with_modes("gpt-5")) - assert "Trimmed avg" in html - assert "P50 (median)" in html - assert "st.trimmed_mean" in html - # A filtered view recomputes it by the same rule gd-eval uses, and says when a sample is too small. + table = html.split("function latencyTable(")[1].split("function modeSection")[0] + assert "const heads = ['Run', 'Fastest', 'Median', 'P90', 'Slowest']" in table + assert "const KEYS = ['min', 'p50', 'p90', 'max']" in table assert "trimmed_mean:" in html.split("function latencyStats(samples)")[1].split("function qualityOf")[0] - assert "Trimmed avg needs at least 10 samples." in html + + +def test_the_latency_tables_mark_the_quickest_and_slowest_run_by_icon_as_well_as_colour(): + html = build_html(_doc_with_modes("gpt-5")) + + table = html.split("function latencyTable(")[1].split("function modeSection")[0] + assert "quickest of the runs shown" in table + assert "slowest of the runs shown" in table + # A chevron carries the meaning too (down = least time, up = most), so the colour is never the only signal. + assert "best: CHEVRON('down'), worst: CHEVRON('up')" in table + assert "latestpill" in html def test_html_uses_the_goodata_tokens_and_loads_no_web_font(): @@ -356,13 +371,127 @@ def test_html_uses_the_goodata_tokens_and_loads_no_web_font(): for token in ("#14b2e2", "#dafe85", "#464e56", "#dde4eb", "#f5f8fa"): assert token in html, f"design token {token} missing" - assert '"Avenir Next", Avenir' in html + # The brand faces are named, never loaded: a viewer without them gets the system sans. + assert "--font: Inter," in html + assert '--font-display: "Funnel Display"' in html # Self-contained: a web font, import or remote stylesheet would break file:// and offline use. assert "@font-face" not in html assert "@import" not in html assert "url(" not in html +def test_the_masthead_names_the_report_and_an_explicit_title_wins(): + html = build_html(_doc("gpt-5", True), title="H200 regression check") + + assert "GoodData.AI · Evaluation report" in html + # --title wins; without one the page builds "Run history: on " from the runs. + assert "DATA.title !== 'gd-eval report'" in html + for kind in ("Run history", "Run comparison", "Run results"): + assert kind in html + assert _embedded(html)["title"] == "H200 regression check" + + +def test_results_open_on_three_headline_cards_that_say_where_the_value_came_from(): + """The report opens on pass rate, quality and time for the latest run. Each carries one change line in words + ("down from 86% on Sep 28") rather than a signed number, coloured by whether that direction is good, with a + chevron and screen-reader text so the colour is never the only signal.""" + html = build_html(_doc_with_modes("gpt-5")) + + cards = html.split("function renderSummary()")[1].split("function sparkHover")[0] + for metric in ("Test cases passed", "Average quality score", "Median time per attempt"): + assert metric in cards + kpi = html.split("function kpiCard(")[1].split("function renderSummary")[0] + for text in ("'best' : 'worst'", "'Better: ' : 'Worse: '", "CHEVRON(", "same as ", "from ${fmt(b.v)}"): + assert text in kpi + # The old results table, its picker and the separate comparison table are gone. + for gone in ('id="comparison"', 'id="trend"', "renderComparison", "renderTrend", "cmp-a", 'class="results"'): + assert gone not in html + + +def test_a_sparkline_appears_from_three_runs_and_a_click_picks_the_run_to_compare_with(): + """Two runs need no history chart; from three each card carries a sparkline of every run. Clicking a point + compares the latest run with that one. A pick that a filter removed, or that became the latest run, is ignored + rather than compared with itself.""" + html = build_html(_doc_with_modes("gpt-5")) + + kpi = html.split("function kpiCard(")[1].split("function renderSummary")[0] + assert "pts.length >= 3" in kpi + assert "function sparkPick(run)" in html + base = html.split("function compareBase()")[1].split("// Median wall-clock")[0] + assert "cmpPick !== last" in base + assert "runsShown.includes(cmpPick)" in base + + +def test_seconds_drop_a_redundant_decimal(): + html = build_html(_doc_with_modes("gpt-5")) + + assert r"v.toFixed(1).replace(/\.0$/, '')" in html + + +def test_the_item_table_says_what_changed_since_the_previous_run(): + html = build_html(_doc_with_modes("gpt-5")) + + assert "Since previous run" in html + for change in ("Regressed", "Fixed", "Flaky", "New", "Removed", "Still failing", "Still passing"): + assert change in html.split("const CHANGE_ORDER")[1].split("function sortVal")[0] + # With several runs the table opens on the regressions; one run has nothing to compare, so it opens on status. + assert "LABELS.length > 1 ? 'chg' : 's:' + LABELS[0]" in html + # Newest first: the latest result, then what changed since the previous run, then earlier runs going back. + assert "runList.slice(0, -1).reverse()" in html + + +def test_the_run_setup_tooltip_names_what_is_the_same_and_what_differs(): + """Results from different setups are not directly comparable, so the page says which setup facts changed + between runs instead of leaving the reader to infer it from a number.""" + html = build_html(_doc_with_modes("gpt-5")) + + for text in ("Run setup", "Same in every run", "Differs between runs", "Attempts per test case", "Pass rule"): + assert text in html + # A field the report does not carry (a redacted workspace, say) is not listed as an empty row. + assert "x.vals.some((v) => v !== '\\u2013')" in html + + +def test_a_filter_popup_stages_its_changes_until_apply(): + """The kind, status and run filters follow GoodData's attribute filter: search, All, values with an Only + shortcut and a count, then Cancel or Apply. Nothing takes effect until Apply.""" + html = build_html(_doc_with_modes("gpt-5")) + + popup = html.split("function makeMulti(")[1].split("const T = {")[0] + for part in ('class="search"', 'class="allrow"', 'class="only"', 'class="cancel"', 'class="apply"'): + assert part in popup + assert "FILTER_TITLE" in popup + # The selection moves only in `apply`; the checkboxes edit a draft. + assert popup.count("m.sel.add(") == 1 + + +def test_the_expression_filter_is_folded_away_and_the_page_has_a_print_stylesheet(): + html = build_html(_doc_with_modes("gpt-5")) + + adv = html.index('
') + assert adv < html.index('id="expr"') < html.index("
", adv) + assert "@media print" in html + print_css = html.split("@media print")[1].split("")[0] + for hidden in ("#filterbar", "#drawer"): + assert hidden in print_css + + +def test_the_template_carries_no_sample_data(): + """The design was drafted against illustrative data. None of its model names, workspaces or flags may + survive into the template, or every report (a redacted one included) would carry them.""" + html = build_html(_doc("some-model", True)) + + for sample in ("gpt-5", "Foundry", "ecommerce", "illustrative", "ILLUSTRATIVE"): + assert sample not in html + + +def test_a_redacted_report_leaves_no_empty_setup_rows(): + html = build_html(_doc("gpt-5", True), redact=True) + + # Workspace and provider are blanked by redaction; the page drops a row it has no value for. + assert "x.vals.some((v) => v !== '\\u2013')" in html + assert _embedded(html)["runs"]["Model A"]["workspace_id"] == "" + + def test_building_the_html_does_not_invent_a_mode_split_for_an_older_report(): """Files written before gd-eval split latency by mode carry no `by_mode`. The page says so; nothing here backfills a figure the run never measured.""" From 7bc56fac199d398e26a2f294fcb482141cd17bb2 Mon Sep 17 00:00:00 2001 From: Jan Brandejs Date: Mon, 12 Oct 2026 16:38:46 +0200 Subject: [PATCH 9/9] fix(gooddata-eval): count timed-out turns and fix HTML change labels Review of the latency report found two places where the response-time population and the scored runs disagree, three where the HTML report disagrees with the console or labels the wrong run, and one repeated computation. A turn that ended in a ChatError (a turn or item deadline, an error event, an incomplete turn) was never recorded as a trace, although evaluators score its partial result as a run. The slowest turns went missing from the per-response percentiles. send_message now records the turn's wall clock from the partial result before re-raising. An item that raised outside its evaluator's own failure reporting kept the turns it had completed but reported no runs, so it showed up in the per-response table and not in the per-conversation one. Its turns are now dropped (TraceRecorder.discard), along with the runs it never reported. The pass-rate card divided by every test case, skipped ones included, while the console divides by those evaluated, so 10 passed of 14 with 2 skipped read 71% against 83%. The card, and the recomputed figures of a filtered view, now use one passRateOf helper. The page's pass_rate figure was read by nothing and is removed; the JSON field is unchanged. The Since previous run labels described earlier runs when they should describe the latest: a test case skipped in the latest run was labelled Regressed beside a SKIP cell, and one absent from the previous run but present before it was labelled New instead of Fixed or Flaky. A skipped latest run now has no label, and New and Removed look at every earlier run. EvalReport.by_mode is a cached_property, since the console, the comparison and both JSON builders each rebuilt and re-sorted it. The README describes the changed definitions. The HTML rules are tested by running the page's own functions in Node, skipped where Node is absent. jira: trivial risk: low Co-Authored-By: Claude Sonnet 5.5 --- packages/gooddata-eval/README.md | 13 ++-- .../src/gooddata_eval/cli/agentic_runner.py | 6 ++ .../src/gooddata_eval/core/chat/sse_client.py | 19 ++++- .../core/reporting/report_template.html | 24 ++++-- .../src/gooddata_eval/core/runner.py | 14 +++- .../src/gooddata_eval/core/timing.py | 5 ++ .../tests/test_agentic_runner.py | 27 ++++++- .../gooddata-eval/tests/test_html_report.py | 78 +++++++++++++++++++ packages/gooddata-eval/tests/test_runner.py | 9 +++ .../gooddata-eval/tests/test_sse_client.py | 27 ++++++- packages/gooddata-eval/tests/test_timing.py | 10 +++ 11 files changed, 205 insertions(+), 27 deletions(-) diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index a9b46bfe8..ffc2499f7 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -340,7 +340,9 @@ Each item also states its `mode`, its `trace_latencies_s` and its `run_latencies Its latency is that turn's response time, from sending the message to the end of its stream (`turn_wall_clock_sec`), recorded where the chat client completes the turn. It holds none of the LLM judge, the simulated user or conversation create/delete. `dashboard_summary` is timed the same way around its one - request. A turn that raised was never completed and has no entry. + request. A turn cut off by its turn or item deadline (or ended by an error event) still counts, with the + time it ran for: those are the slowest turns, and the run that scored them is in the per-run figures too. An + item that errored outside its evaluator's own failure reporting contributes neither. - **Single-shot** runs are exactly one trace, so `traces_s` is also the latency of a run. - **Multi-turn** runs span several traces, so they get both views: `traces_s` over every turn of every conversation (comparable with single-shot), and `runs_s`, the wall clock of each whole conversation from just @@ -409,8 +411,8 @@ simulated user), which are `single_shot` and `multi_turn` in the JSON. the runs in play has it (so "Failed" over previous and latest finds every test case that broke in either), and the expression is evaluated once per run in play with the same any-run rule. A filtered page states how many test cases it covers, and each tier has its own Reset. -- **Results** — three headline cards for the latest run: test cases passed, the average quality score and the median - time per attempt. With an earlier run each card carries one change line in words, "down from 86% on Sep 28", rather +- **Results** — three headline cards for the latest run: test cases passed (of those evaluated: skipped ones are not + counted, as in the console's percentage), the average quality score and the median time per attempt. With an earlier run each card carries one change line in words, "down from 86% on Sep 28", rather than a signed number. The line is green when that direction is good and red when it is not, with a chevron and screen-reader text (*Better* / *Worse*) so colour is not the only signal. From three runs each card also has a sparkline of every run: hover a point for its value, click it to compare the latest run with that one instead of the @@ -428,8 +430,9 @@ simulated user), which are `single_shot` and `multi_turn` in the JSON. decimal and no redundant ".0" (`3s`, `4.6s`). - **A test case table**, newest first: the latest run's result, a **Since previous run** column, then the earlier runs going back in time (click any result to open the drawer on that run). The change column reads *Regressed* (passed in - the previous run, not in the latest), *Fixed*, *Flaky* (flipped between pass and fail more than once), *New* or - *Removed*. With several runs the table opens on the regressions. Two time columns + the previous run, not in the latest), *Fixed*, *Flaky* (flipped between pass and fail more than once), *New* + (in no earlier run) or *Removed* (in an earlier run, gone from the latest). A test case skipped in the latest run + has no label, since the change would describe earlier runs. With several runs the table opens on the regressions. Two time columns keep the populations apart: **Response time** is the median of the test case's assistant responses, and **Time per attempt** is the mean wall-clock of a whole attempt (the responses plus the judge and, for conversations, the simulated user). With more than two runs shown they follow the latest run only. Hover a value to see every sample diff --git a/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py index ef92146e3..d66a83b61 100644 --- a/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py +++ b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py @@ -545,6 +545,12 @@ def _process_item(index: int, item: DatasetItem) -> ItemReport: # judge broke should not also report the agent as costing 0s. Kinds that # attach no timings to the exception keep their 0.0 defaults. _apply_timings(item_report, getattr(exc, "timings", None)) + # Phase timings stay, but the per-turn response times do not: this item reports no + # runs (and so no run latencies), and keeping its turns would put them in the + # response population with no attempt beside them. An item that completed 2 of 3 + # runs and then raised would otherwise show up in the per-response table and not in + # the per-conversation one. + traces.discard() finally: item_report.latency_s = time.perf_counter() - t0 item_report.trace_latencies_s = traces.stop() diff --git a/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py b/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py index bcd386759..3c53426f6 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py @@ -588,10 +588,21 @@ def _do() -> ChatResult: result.turn_wall_clock_sec = time.monotonic() - t0 return result - result = _retry_transient(_do, is_retryable=_is_retryable_exc) - # One completed turn is one gen-ai trace. Recorded here, from the figure the turn already - # carries, so every caller is covered and no extra clock is read. A turn that raised is - # not recorded: it has no completed response to time. + try: + result = _retry_transient(_do, is_retryable=_is_retryable_exc) + except ChatError as exc: + # A turn that ended in a ChatError (a turn or item deadline, an error event, an + # incomplete turn) is still one gen-ai trace, and several evaluators score its + # partial result as a run. Recording it keeps the response population matching the + # runs that were scored: left out, the slowest turns -- the ones that hit a deadline -- + # would vanish and pull the tail down exactly when the agent is slowest. + if exc.partial_result is not None: + note_trace_latency(exc.partial_result.turn_wall_clock_sec) + raise + # One turn is one gen-ai trace. Recorded here, from the figure the turn already carries, + # so every caller is covered and no extra clock is read. A turn that failed without a + # ChatError carries no partial result, so there is no response to time and it is not + # recorded. note_trace_latency(result.turn_wall_clock_sec) return result diff --git a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html index eff51e642..a0f4a2030 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html +++ b/packages/gooddata-eval/src/gooddata_eval/core/reporting/report_template.html @@ -415,13 +415,15 @@

Test cases

const total = c.total ?? s.total ?? 0, passed = c.passed ?? s.passed ?? 0; return { total, passed, errored: s.errored ?? 0, skipped: s.skipped ?? 0, - pass_rate: c.pass_rate ?? (total ? passed / total : 0), avg_quality_score: c.avg_quality_score, avg_latency_s: c.avg_latency_s ?? s.avg_latency_s, total_latency_s: c.total_latency_s ?? s.latency_s, by_mode: c.by_mode || s.by_mode || null, }; } +// Share of the evaluated test cases that passed, for reported and recomputed figures alike. A skipped test case never +// ran, so it is left out of the divisor, as the console's percentage does; null when nothing was evaluated. +const passRateOf = (f) => (f.total - f.skipped > 0 ? f.passed / (f.total - f.skipped) : null); const r3 = (v) => Math.round(v * 1000) / 1000; // Linear interpolation between closest ranks, as gd-eval computes it, so P50 is the median. function percentile(sorted, p) { @@ -459,7 +461,7 @@

Test cases

} return { total: items.length, passed, errored: items.filter((it) => it.error != null).length, - skipped: items.filter((it) => it.skipped).length, pass_rate: items.length ? passed / items.length : 0, + skipped: items.filter((it) => it.skipped).length, avg_quality_score: evaluated.length ? mean(evaluated.map(qualityOf)) : 0, avg_latency_s: runs ? latency / runs : 0, total_latency_s: latency, by_mode, }; @@ -521,7 +523,7 @@

Test cases

function renderSummary() { const base = runsShown.length > 1 ? compareBase() : null; $('#summary').innerHTML = '

Results

' + - kpiCard({ title: 'Test cases passed', get: (l) => (figures[l].total ? figures[l].passed / figures[l].total : null), fmt: pct, higher: true, + kpiCard({ title: 'Test cases passed', get: (l) => passRateOf(figures[l]), fmt: pct, higher: true, isSame: (d) => Math.round(d * 100) === 0, base, after: ' which?' }) + kpiCard({ title: 'Average quality score', get: (l) => (figures[l].total ? figures[l].avg_quality_score : null), fmt: pct, higher: true, isSame: (d) => Math.abs(d) < 0.01, base }) + @@ -843,14 +845,20 @@

Test cases

return (va < vb ? -1 : va > vb ? 1 : 0) * sortDir; }); } -// How a test case moved between the last two runs shown, and whether it has flipped back and forth across all of them. +// How a test case moved between the last two runs it was evaluated in, and whether it has flipped back and forth across +// all of them. Every label describes the latest run, so none is given when that run skipped the test case or lacks it. const CHANGE_ORDER = { Regressed: 0, New: 1, Removed: 2, Flaky: 3, 'Still failing': 4, Fixed: 5, 'Still passing': 6, '': 7 }; function changeOf(r, runList) { + const cur = r.per[runList[runList.length - 1]]; + // New and Removed look at every earlier run, not just the one before: a test case that skipped a run and came back + // is not new, and its pass or fail history still says whether it was fixed or is flaky. + const earlier = runList.slice(0, -1).some((l) => r.per[l]); if (runList.length > 1) { - const prevIt = r.per[runList[runList.length - 2]], curIt = r.per[runList[runList.length - 1]]; - if (curIt && !prevIt) return 'New'; - if (prevIt && !curIt) return 'Removed'; + if (cur && !earlier) return 'New'; + if (!cur && earlier) return 'Removed'; } + // Skipped in the latest run (or missing from it): the earlier runs alone would label a result the row does not show. + if (['none', 'skip'].includes(statusOf(cur))) return ''; const st = runList.map((l) => statusOf(r.per[l])).filter((x) => x !== 'none' && x !== 'skip'); if (st.length < 2) return ''; const ok = st.map((x) => x === 'pass'); @@ -926,7 +934,7 @@

Test cases

{ k: 'id', html: 'Test case' }, { k: 'kind', html: 'Category' }, { k: 'mode', html: 'Format' }, { k: 'question', html: 'Question' }, // Newest first: the latest result, what changed since the previous run, then earlier runs going back in time. ...(multi ? [{ k: 's:' + runList[runList.length - 1], html: esc(roleOf(runList[runList.length - 1])) + LATEST_PILL }] : []), - ...(multi ? [{ k: 'chg', html: 'Since previous run', help: 'Regressed: passed in the previous run, not in the latest. Fixed: the reverse. Flaky: has flipped between pass and fail more than once across the runs shown. New / Removed: the test case was added to or dropped from the suite.' }] : []), + ...(multi ? [{ k: 'chg', html: 'Since previous run', help: 'Regressed: passed in the previous run, not in the latest. Fixed: the reverse. Flaky: has flipped between pass and fail more than once across the runs shown. New / Removed: the test case first appears in the latest run, or was in an earlier run and is gone from the latest. A test case skipped in the latest run has no label.' }] : []), ...(multi ? runList.slice(0, -1).reverse() : runList).map((l) => ({ k: 's:' + l, html: esc(roleOf(l)) })), ...timeRuns.map((l) => ({ k: 'lt:' + l, html: 'Response time' + runHdr(l), help: TRACE_HELP, num: true })), ...timeRuns.map((l) => ({ k: 'lr:' + l, html: 'Time per attempt' + runHdr(l), help: RUN_HELP, num: true })), diff --git a/packages/gooddata-eval/src/gooddata_eval/core/runner.py b/packages/gooddata-eval/src/gooddata_eval/core/runner.py index 5496b8a23..e6d2c8d84 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/runner.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/runner.py @@ -5,7 +5,7 @@ import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field -from functools import partial +from functools import cached_property, partial from typing import Callable, Protocol from gooddata_eval.core.config import DEFAULT_GATE, EvalGate @@ -67,8 +67,10 @@ class ItemReport: # The response time of every chat turn the item completed, in order -- one gen-ai trace # each, timed from sending the message to the end of its stream. For a single-shot item that # is one value per run; a multi-turn run contributes one per turn. Unlike `run_latencies_s` - # it holds none of the judge, simulated-user or conversation set-up time. A turn that raised - # has no entry, but a turn whose scoring raised afterwards does: it completed. + # it holds none of the judge, simulated-user or conversation set-up time. A turn that ended in + # a ChatError carrying a partial result has an entry, because evaluators score that result as a + # run; so does a turn whose scoring raised afterwards. An item that errored outside the + # evaluator's own failure reporting has none: it reports no runs for them to sit beside. trace_latencies_s: list[float] = field(default_factory=list) # Per-phase breakdown of what the item's time was spent on. Additive to latency_s, # which remains the item's own critical path. langfuse_latency_s is @@ -200,10 +202,14 @@ def avg_quality_score(self) -> float: evaluated = [i.quality_score for i in self.items if not i.skipped and i.error is None] return sum(evaluated) / len(evaluated) if evaluated else 0.0 - @property + @cached_property def by_mode(self) -> dict[str, dict]: """How the run did on its single-shot and on its multi-turn items, with latency for each. + Computed on first read and kept: the console, the comparison and both JSON builders all + read it, and each read would otherwise sort every latency sample again. Read it once the + report is complete -- items added afterwards are not reflected. + ``total`` and ``passed`` count the evaluated items of that mode (skipped ones belong to neither). ``traces_s`` is the distribution of per-trace chat-turn response times; a single-shot run is one trace, so for that mode it is also the per-run latency. A diff --git a/packages/gooddata-eval/src/gooddata_eval/core/timing.py b/packages/gooddata-eval/src/gooddata_eval/core/timing.py index a9248f912..b2d203d7b 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/timing.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/timing.py @@ -167,6 +167,11 @@ def stop(self) -> list[float]: self._token = None return self.latencies + def discard(self) -> None: + """Detach and drop what was recorded, for an item whose turns have no scored run to sit beside.""" + self.stop() + self.latencies.clear() + def note_trace_latency(seconds: float | None) -> None: """Record one completed chat turn on the active recorder, if there is one. diff --git a/packages/gooddata-eval/tests/test_agentic_runner.py b/packages/gooddata-eval/tests/test_agentic_runner.py index 81dfede87..f331bb11d 100644 --- a/packages/gooddata-eval/tests/test_agentic_runner.py +++ b/packages/gooddata-eval/tests/test_agentic_runner.py @@ -1065,14 +1065,33 @@ def test_turns_completed_before_a_failed_gate_still_count_as_traces(): assert report.items[0].trace_latencies_s == [3.0, 4.0] -def test_turns_completed_before_an_item_errored_still_count_as_traces(): - """The agent answered those turns however the item ended, so they are real response times.""" +def test_an_item_that_errored_outside_its_evaluator_reports_no_trace_latencies(): + """Such an item reports no runs, so keeping its turns would put them in the per-response + population with nothing beside them in the per-conversation one.""" with patch( "gooddata_eval.cli.agentic_runner.evaluate_agentic_alert_skill", - side_effect=_noting(2.0, then=RuntimeError("simulated user failed")), + side_effect=_noting(2.0, 3.0, then=RuntimeError("simulated user failed")), ): report = run_agentic_items([_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") - assert report.items[0].error is not None + item = report.items[0] + assert item.error is not None + assert item.runs == 0 + assert item.run_latencies_s == [] + assert item.trace_latencies_s == [] + assert report.by_mode["multi_turn"]["traces_s"] is None + + +def test_an_ungraded_item_keeps_the_turns_it_drove(): + """Unlike the case above, a judge fault leaves every run's time on the exception, so the + per-response and per-conversation populations still cover the same item.""" + err = JudgeResponseError("judge returned no readable verdict for any of the 2 run(s)") + err.runs_effective = 2 + err.run_latencies_s = [7.0, 9.5] + with patch("gooddata_eval.cli.agentic_runner.evaluate_agentic_alert_skill", side_effect=_noting(2.0, then=err)): + report = run_agentic_items( + [_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01", k=2 + ) + assert report.items[0].run_latencies_s == [7.0, 9.5] assert report.items[0].trace_latencies_s == [2.0] diff --git a/packages/gooddata-eval/tests/test_html_report.py b/packages/gooddata-eval/tests/test_html_report.py index 9ed75073e..7003fae5d 100644 --- a/packages/gooddata-eval/tests/test_html_report.py +++ b/packages/gooddata-eval/tests/test_html_report.py @@ -1,6 +1,8 @@ # (C) 2026 GoodData Corporation import json import re +import shutil +import subprocess import orjson import pytest @@ -440,6 +442,82 @@ def test_the_item_table_says_what_changed_since_the_previous_run(): assert "runList.slice(0, -1).reverse()" in html +def _run_page_js(html: str, definitions: list[str], expression: str): + """Evaluate `expression` in Node against functions lifted verbatim out of the page's script, so the + rules are exercised as written rather than asserted as text. There is no JS test runner in this package.""" + parts = [] + for name in definitions: + found = re.search(rf"^(?:const {name} = [^\n]*;|function {name}\(.*?^\}})$", html, re.M | re.S) + assert found, f"{name} not found in the page" + parts.append(found.group(0)) + script = "\n".join(parts) + f"\nconsole.log(JSON.stringify({expression}));" + done = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=True) + return json.loads(done.stdout) + + +_needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="the page's script is run in Node") +_PAGE_STATUS = ["passedOf", "statusOf"] + + +@_needs_node +def test_the_pass_rate_card_leaves_skipped_test_cases_out_like_the_console(): + """The console divides by the evaluated test cases, so the page must or the two disagree: 10 passed of 14 with + 2 skipped is 83% in the console, not 71%.""" + html = build_html(_doc_with_modes("gpt-5")) + + rates = _run_page_js( + html, + ["passRateOf"], + "[passRateOf({total: 14, skipped: 2, passed: 10}), passRateOf({total: 2, skipped: 2, passed: 0}), " + "passRateOf({total: 0, skipped: 0, passed: 0})]", + ) + + assert rates == [pytest.approx(10 / 12), None, None] + # Both the reported and the recomputed figures carry `skipped`, which is what the card divides by. + assert "get: (l) => passRateOf(figures[l])" in html + assert "passed / items.length" not in html + + +_ITEM_RESULT = {"pass": {"gate_passed": True}, "fail": {"gate_passed": False}, "skip": {"skipped": True}} + + +def _changes(html: str, runs: dict[str, str | None]) -> str: + """The change label for a test case whose result in each run is pass, fail, skip or None (not in that run).""" + row = {"per": {label: _ITEM_RESULT[result] for label, result in runs.items() if result is not None}} + return _run_page_js(html, [*_PAGE_STATUS, "changeOf"], f"changeOf({json.dumps(row)}, {json.dumps(list(runs))})") + + +@_needs_node +@pytest.mark.parametrize( + ("runs", "label"), + [ + # Skipped in the latest run: the earlier runs alone would call this Regressed, beside a SKIP cell. + ({"a": "pass", "b": "fail", "c": "skip"}, ""), + ({"a": "fail", "b": "pass", "c": "skip"}, ""), + ({"a": "pass", "b": "pass", "c": "skip"}, ""), + # A skip in the middle is looked through: the latest run still decides the label. + ({"a": "pass", "b": "skip", "c": "fail"}, "Regressed"), + ({"a": "pass", "b": "fail"}, "Regressed"), + ({"a": "fail", "b": "pass"}, "Fixed"), + ({"a": "pass", "b": "pass"}, "Still passing"), + ({"a": "fail", "b": "fail"}, "Still failing"), + ({"a": "fail", "b": "pass", "c": "fail", "d": "pass"}, "Flaky"), + # Absent from the previous run but present before it: back, not new. + ({"a": "fail", "b": None, "c": "pass"}, "Fixed"), + ({"a": "pass", "b": None, "c": "fail"}, "Regressed"), + ({"a": "pass", "b": None, "c": "pass"}, "Still passing"), + ({"a": None, "b": "pass"}, "New"), + ({"a": None, "b": None, "c": "fail"}, "New"), + ({"a": "pass", "b": None}, "Removed"), + ({"a": "pass", "b": "pass", "c": None}, "Removed"), + # One run has nothing to compare with. + ({"a": "pass"}, ""), + ], +) +def test_a_change_label_describes_the_latest_run_against_the_whole_history(runs, label): + assert _changes(build_html(_doc_with_modes("gpt-5")), runs) == label + + def test_the_run_setup_tooltip_names_what_is_the_same_and_what_differs(): """Results from different setups are not directly comparable, so the page says which setup facts changed between runs instead of leaving the reader to infer it from a number.""" diff --git a/packages/gooddata-eval/tests/test_runner.py b/packages/gooddata-eval/tests/test_runner.py index ee9331f83..91166cdbf 100644 --- a/packages/gooddata-eval/tests/test_runner.py +++ b/packages/gooddata-eval/tests/test_runner.py @@ -643,6 +643,15 @@ def test_by_mode_keeps_single_shot_and_multi_turn_apart(): assert by_mode["multi_turn"]["runs_s"]["max"] == 20.0 +def test_by_mode_is_computed_once_per_report(): + """The console, the comparison and both JSON builders all read it; each read used to sort + every latency sample again.""" + report = EvalReport(model="m") + report.items = [_item_report("visualization", traces=[1.0, 2.0], runs=[1.5])] + + assert report.by_mode is report.by_mode + + def test_by_mode_never_mixes_the_populations(): """Single-shot traces must not be pulled up by a conversation's turns, or the main per-trace figure would describe nothing in particular.""" diff --git a/packages/gooddata-eval/tests/test_sse_client.py b/packages/gooddata-eval/tests/test_sse_client.py index 6c4f2238a..21c07105c 100644 --- a/packages/gooddata-eval/tests/test_sse_client.py +++ b/packages/gooddata-eval/tests/test_sse_client.py @@ -638,12 +638,35 @@ def test_send_message_records_the_turn_as_a_trace_latency(monkeypatch): assert recorder.stop() == [pytest.approx(2.5)] -def test_send_message_records_nothing_for_a_turn_that_raised(monkeypatch): - # No completed response, so nothing to time: a failed turn must not enter the percentiles. +def test_send_message_records_a_turn_that_ended_in_a_chat_error(monkeypatch): + # Evaluators score the partial result of such a turn as a run, so the response population has to + # hold it too, or the two disagree about which turns happened. + monkeypatch.setattr(sse_mod.time, "monotonic", iter([50.0, 50.0, 51.0]).__next__) client = _client_with_handler(lambda request: httpx.Response(200, content=_NONRETRY_SSE)) recorder = TraceRecorder() with pytest.raises(ChatError): client.send_message("conv", "q") + assert recorder.stop() == [pytest.approx(1.0)] + + +def test_send_message_records_a_turn_cut_off_by_its_deadline(monkeypatch): + """The slowest turns are the ones that hit the budget; leaving them out would make the tail + look shorter exactly when the agent is slowest.""" + # monotonic() calls: t0, the accumulator's own t0, the deadline check on the first line, the wall-clock. + monkeypatch.setattr(sse_mod.time, "monotonic", iter([0.0, 0.0, 61.0, 61.0]).__next__) + client = _client_with_handler(lambda request: httpx.Response(200, content=_OK_SSE), turn_timeout_s=60) + recorder = TraceRecorder() + with pytest.raises(sse_mod.TurnTimeoutError): + client.send_message("conv", "q") + assert recorder.stop() == [pytest.approx(61.0)] + + +def test_send_message_records_nothing_for_a_turn_that_failed_without_a_partial_result(): + # An HTTP error before any stream opens leaves no partial result: no response to time. + client = _client_with_handler(lambda request: httpx.Response(400)) + recorder = TraceRecorder() + with pytest.raises(httpx.HTTPStatusError): + client.send_message("conv", "q") assert recorder.stop() == [] diff --git a/packages/gooddata-eval/tests/test_timing.py b/packages/gooddata-eval/tests/test_timing.py index 5b1f58ed5..b2de2e19b 100644 --- a/packages/gooddata-eval/tests/test_timing.py +++ b/packages/gooddata-eval/tests/test_timing.py @@ -241,6 +241,16 @@ def test_stopping_detaches_the_recorder_and_is_safe_twice(): assert recorder.stop() == [1.0] +def test_discarding_drops_what_was_recorded_and_detaches(): + recorder = TraceRecorder() + note_trace_latency(1.0) + + recorder.discard() + note_trace_latency(9.0) # after discard: nobody is listening + + assert recorder.stop() == [] + + def test_concurrent_items_each_record_only_their_own_traces(): """Items run in worker threads under --concurrency; one item's turns must never land in another's list."""