diff --git a/packages/gooddata-eval/README.md b/packages/gooddata-eval/README.md index d6b614955..ffc2499f7 100644 --- a/packages/gooddata-eval/README.md +++ b/packages/gooddata-eval/README.md @@ -308,13 +308,69 @@ 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: single-shot and multi-turn + +`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_document_skill`, `agentic_kda_skill`, `agentic_anomaly_detection`, `agentic_what_if` and + `agentic_conversation`. + +```json +"summary": { + "by_mode": { + "single_shot": {"total": 12, "passed": 10, + "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, "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}} + } +} +``` + +`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 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 + 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 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 +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. + --- ## `gd-eval report` 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). 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 @@ -333,25 +389,74 @@ 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 numbers of its own. It gives you: - -- **Run cards and a comparison table** — pass rate, quality, latency per run. -- **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 - anticipate, e.g. `d.filter_ranking_score === false` or +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. 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 (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 + 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* + (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 + 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 focused 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/cli/agentic_runner.py b/packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py index 09d0dc267..d66a83b61 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): @@ -375,6 +376,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. @@ -447,6 +460,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( @@ -482,6 +499,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 @@ -492,6 +510,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. @@ -512,6 +531,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. @@ -525,8 +545,15 @@ 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() if on_item_done is not None: try: 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 a5854ff82..1d581eea7 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 @@ -1166,6 +1166,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 @@ -1398,6 +1400,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() @@ -1474,6 +1477,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, ) @@ -1699,6 +1703,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, @@ -1709,4 +1714,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/document_skill.py b/packages/gooddata-eval/src/gooddata_eval/core/agentic/document_skill.py index 9112f3e02..f8b8824ae 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/agentic/document_skill.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/agentic/document_skill.py @@ -548,6 +548,8 @@ class DocumentRunResult: 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]: @@ -621,6 +623,7 @@ def _execute_single_document_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() @@ -691,6 +694,7 @@ def _execute_single_document_run( tool_call_events=all_tool_call_events, reasoning_step_events=all_reasoning_step_events, timings=timings, + run_latency_s=time.monotonic() - run_started, ) @@ -882,6 +886,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) @@ -935,6 +940,7 @@ def _run_detail(run: DocumentRunResult) -> 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, @@ -945,4 +951,5 @@ def _run_detail(run: DocumentRunResult) -> 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/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/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/chat/sse_client.py b/packages/gooddata-eval/src/gooddata_eval/core/chat/sse_client.py index ba7dae57b..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 @@ -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,23 @@ def _do() -> ChatResult: result.turn_wall_clock_sec = time.monotonic() - t0 return result - return _retry_transient(_do, is_retryable=_is_retryable_exc) + 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 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/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/interaction.py b/packages/gooddata-eval/src/gooddata_eval/core/interaction.py new file mode 100644 index 000000000..2a6e4cbfa --- /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_document_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/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..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,7 +5,14 @@ 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 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) def _ungraded_note(item: ItemReport) -> str: @@ -17,6 +24,44 @@ def _ungraded_note(item: ItemReport) -> str: return f"{criteria} criterion(s) ungraded" if criteria else "" +_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 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, {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: """Render the report to the console and return the rendered text. @@ -89,6 +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}" ) + for line in _mode_lines(report): + out.print(line) return out.export_text() if out.record else "" @@ -109,21 +156,26 @@ 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"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.by_mode[SINGLE_SHOT]["traces_s"] 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", ) 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 55d922864..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,11 +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), + # 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 @@ -47,6 +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 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 @@ -93,6 +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), + "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 209124775..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 @@ -3,126 +3,270 @@ - -gd-eval report + +GoodData.AI evaluation report -
+
+
GoodData.AI · Evaluation report

-
-
-
-

Items

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

Test cases

+

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

+
+ + + +
+
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. +
+
@@ -136,7 +280,15 @@

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. +// 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; + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} // ---------- rows: one per item id, holding that id's result in every run ---------- const rows = []; @@ -145,62 +297,494 @@

Items

for (const [id, item] of Object.entries(DATA.runs[label].items || {})) { let r = byId.get(id); if (!r) { - r = { id, kind: item.test_kind || '', dataset: item.dataset_name || '', question: item.question || '', per: {} }; + r = { id, kind: item.test_kind || '', mode: item.mode || '', dataset: item.dataset_name || '', question: item.question || '', per: {} }; byId.set(id, r); rows.push(r); } r.per[label] = item; if (!r.question) r.question = item.question || ''; if (!r.kind) r.kind = item.test_kind || ''; + if (!r.mode) r.mode = item.mode || ''; } } -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: '–' }; +// 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: '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 ---------- -$('#title').textContent = DATA.title || 'gd-eval report'; -$('#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' : ''); -// ---------- summary cards ---------- -$('#summary').innerHTML = '

Runs

' + LABELS.map((label) => { - const s = DATA.runs[label].summary || {}; - const rate = s.total ? s.passed / s.total : 0; - 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
-
`; -}).join('') + '
'; +// ---------- 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-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 -// ---------- comparison ---------- -if (LABELS.length > 1) { - 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'], - ['Total latency', (l) => num(cmp(l).total_latency_s, 1) + 's', 'num'], - ]; - $('#comparison').innerHTML = '

Comparison

' + - cols.map(([h, , c]) => ``).join('') + '' + - LABELS.map((l) => '' + cols.map(([, f, c]) => ``).join('') + '').join('') + - '
${h}
${f(l)}
'; +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, + 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) { + 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); +} +function latencyStats(samples) { + const s = [...samples].sort((a, b) => a - b); + if (!s.length) return null; + // 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. +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 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 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, + 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); + +// 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]; +} + +// 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) => 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 }) + + 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). +const HEAD_HELP = { + Median: 'Half were faster than this, half slower (P50).', + P90: '90% finished within this time.', +}; +// 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 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}
` + + (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 ''; + return `

${title}

${blurb}

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

${caption}

` : '') + latencyTable({ mode, ...spec })).join(''); +} +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-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 ---------- +// The same two populations as the latency section above, one figure each per item: its chat turns (traces, the +// main figure) and its whole runs. Items from a report written before gd-eval recorded either list have no trace +// figure, and their run figure falls back to the per-run average those reports always carried. +const traceLatency = (it) => (it && (it.trace_latencies_s || []).length ? median(it.trace_latencies_s) : null); +function runLatency(it) { + if (!it) return null; + if ((it.run_latencies_s || []).length) return mean(it.run_latencies_s); + return it.runs ? (it.avg_latency_s ?? null) : null; +} +// Every sample behind a figure, for the cell's tooltip: a median of three hides a 30s outlier. +function samplesTitle(xs, noun) { + if (!(xs || []).length) return ''; + 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 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)}`; // ---------- filters ---------- +// 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: 'Could not be graded' }, { value: 'skip', text: 'Skipped' }, +]; +function controlsHtml(p, runsTitle) { + const multi = (id, title) => + `
`; + 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); +} +$('#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'); + +// 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)); + 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. + 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('') + + '
'; + loadDraft(); + } + 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.sel.clear(); loadDraft(); refresh(); }; + const apply = () => { + m.sel.clear(); + 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) => { + 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(); + } + }); + el.addEventListener('toggle', () => { + 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; +} +document.addEventListener('click', (e) => { + document.querySelectorAll('.multi[open]').forEach((d) => { if (!d.contains(e.target)) d.open = false; }); +}); +document.addEventListener('keydown', (e) => { + const d = e.key === 'Escape' && document.querySelector('.multi[open]'); + if (d) { d.open = false; d.querySelector('summary').focus(); } +}); + +const T = { // the filters at the top: they decide what the whole page covers + kind: makeMulti($('#top-kind'), 'kinds', render), + mode: $('#top-mode'), + 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', renderItems), + mode: $('#items-mode'), + 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(); -$('#kind').innerHTML = '' + kinds.map((k) => ``).join(''); -$('#focus').innerHTML = LABELS.map((l) => ``).join(''); -let focus = LABELS[0]; -let sortKey = 'id', sortDir = 1, selected = null; +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: kindName(k), count: countBy(rows)[k] }))); +T.status.set(STATUS_OPTIONS); +T.runs.set(LABELS.map((l) => ({ value: l, text: roleOf(l) }))); +I.status.set(STATUS_OPTIONS); + +let sortKey = LABELS.length > 1 ? 'chg' : 's:' + LABELS[0], sortDir = 1, selected = null, drawerRun = null, drawerSig = ''; function exprFn() { const src = $('#expr').value.trim(); @@ -217,89 +801,264 @@

Items

} } -function visible() { - const text = $('#q').value.trim().toLowerCase(); - const kind = $('#kind').value, status = $('#status').value, fn = exprFn(); +// 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 (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 = rows.filter((r) => { - const it = r.per[focus]; - if (kind && r.kind !== kind) return false; - if (status !== 'all' && statusOf(it) !== status) return 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'); - const key = sortKey; - out.sort((a, b) => { + return out; +} +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) { + 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; } +// 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) { + 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'); + 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 === 'id' || key === 'kind' || key === 'question') return r[key] || ''; - if (key === 'latency') return (r.per[focus] || {}).avg_latency_s ?? -1; - return LBL[statusOf(r.per[key])] || ''; // a run column + 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)]; + if (tag === 'lt') return traceLatency(it) ?? -1; + if (tag === 'lr') return runLatency(it) ?? -1; + return LBL[statusOf(it)] || ''; // a run's status column } +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() { + $('#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(`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, 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: 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, 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 +// item table (renderItems). function render() { - const list = visible(); - $('#count').textContent = `${list.length} of ${rows.length}`; - const heads = [['id', 'Item'], ['kind', 'Kind'], ['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('') + ''; - $('#items tbody').innerHTML = list.map((r) => { - const it = r.per[focus] || {}; - return ` + 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(); + renderLatency(); + renderItems(); +} +function renderItems() { + const runList = itemRuns(); + const list = sortRows(itemRows(runList), activeSortKey(runList)); + const key = activeSortKey(runList), multi = runList.length > 1; + $('#items-count').textContent = list.length !== dashRows.length ? `Showing ${list.length} of ${dashRows.length}` : ''; + $('#items-reset').hidden = !itemsActive(); + 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: '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 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 })), + ]; + $('#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)}` + - LABELS.map((l) => { const s = statusOf(r.per[l]); return `${LBL[s]}`; }).join('') + - `${num(it.avg_latency_s)}s`; - }).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. + const row = selected && byId.get(selected); + if (row && $('#drawer').classList.contains('open') && (pickDrawerRun(row) !== drawerRun || drawerSig !== runList.join('\n'))) { + drawerRun = pickDrawerRun(row); + openDrawer(row); + } } -$('#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(); + 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) { + 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; + 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)); } + 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) => { + 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', () => { - 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 ---------- +// 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} 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)}`, + '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]; + 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)}

+ d.innerHTML = ` + ${heading}
${esc(r.id)}

${esc(r.question)}

-
${LBL[st]} - ${esc(r.kind)} +
${chipHtml(st)} + ${esc(kindName(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)}
` : ''} @@ -308,7 +1067,7 @@

${esc(focus)}

${timelineHtml(it)} ${reasoningHtml(it)} ${idsHtml(it)}`; - d.querySelector('.close').onclick = () => { d.classList.remove('open'); selected = null; render(); }; + wire(); d.querySelectorAll('.tl .row').forEach((el) => { el.onclick = () => { const box = $('#tl-detail'); @@ -426,11 +1185,11 @@

${esc(focus)}

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)` : '') + `
`; } @@ -447,6 +1206,48 @@

${esc(focus)}

} 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/src/gooddata_eval/core/runner.py b/packages/gooddata-eval/src/gooddata_eval/core/runner.py index 7e62dc499..e6d2c8d84 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/runner.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/runner.py @@ -5,13 +5,15 @@ 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 from gooddata_eval.core.evaluators import get_evaluator, supported_test_kinds from gooddata_eval.core.evaluators.base import ItemEvaluation +from gooddata_eval.core.interaction import INTERACTION_MODES, MULTI_TURN, InteractionMode, interaction_mode from gooddata_eval.core.models import ChatResult, DatasetItem +from gooddata_eval.core.timing import latency_stats class ChatBackend(Protocol): @@ -57,6 +59,19 @@ 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) + # 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 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 # deliberately NOT part of that path -- trace linking runs off it (see @@ -80,6 +95,11 @@ class ItemReport: # trusting K there reports four runs that never happened. runs_effective: int | None = None + @property + def interaction_mode(self) -> InteractionMode: + """Single-shot or multi-turn, by the item's kind: which latency section it belongs to.""" + return interaction_mode(self.test_kind) + @property def passed(self) -> bool: """Whether the item counts as a pass: the gate's verdict where one ran, else pass@K. @@ -182,6 +202,34 @@ 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 + @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 + multi-turn conversation spans several traces, so that mode also carries ``runs_s``: the + whole-conversation wall clock per run, judge and simulated user included. Each is None + when nothing was timed. + """ + out: dict[str, dict] = {} + for mode in INTERACTION_MODES: + items = [i for i in self.items if i.interaction_mode == mode and not i.skipped] + entry: dict = { + "total": len(items), + "passed": sum(1 for i in items if i.passed), + "traces_s": latency_stats(t for i in items for t in i.trace_latencies_s), + } + if mode == MULTI_TURN: + entry["runs_s"] = latency_stats(t for i in items for t in i.run_latencies_s) + out[mode] = entry + return out + # A per-run progress callback: (run_index, runs_total, passed, latency_s) -> None RunCallback = Callable[[int, int, bool, float], None] @@ -234,12 +282,16 @@ def _run_one_item( for run_index in range(1, runs + 1): t0 = time.perf_counter() chat_result = backend.ask(item) + trace_latency = getattr(chat_result, "turn_wall_clock_sec", None) + if isinstance(trace_latency, (int, float)): + report.trace_latencies_s.append(trace_latency) report.conversation_id = getattr(chat_result, "conversation_id", None) or report.conversation_id report.response_id = getattr(chat_result, "response_id", None) or report.response_id evaluation = evaluator.evaluate(item, chat_result) 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/summary/http_client.py b/packages/gooddata-eval/src/gooddata_eval/core/summary/http_client.py index bab285e04..9d9283d48 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/summary/http_client.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/summary/http_client.py @@ -13,9 +13,12 @@ If the route is ever renamed (e.g. to ``/summarize``), change ``_PATH`` only. """ +import time + import httpx from gooddata_eval.core.models import ChatResult, DatasetItem +from gooddata_eval.core.timing import note_trace_latency _PATH = "/api/v1/ai/workspaces/{workspace_id}/summary" @@ -44,11 +47,18 @@ def ask(self, item: DatasetItem) -> ChatResult: if si.format_hint is not None: body["formatHint"] = si.format_hint + started = time.monotonic() resp = self._client.post(self._url, json=body, headers={**self._auth, "Content-Type": "application/json"}) resp.raise_for_status() data = resp.json() + elapsed = time.monotonic() - started summary = data.get("summary") or "" - return ChatResult.model_validate({"textResponse": summary}) + result = ChatResult.model_validate({"textResponse": summary}) + # The request is this item's one trace, timed like a chat turn so the single-shot latency + # covers dashboard_summary on the same footing. + result.turn_wall_clock_sec = elapsed + note_trace_latency(elapsed) + return result def close(self) -> None: self._client.close() diff --git a/packages/gooddata-eval/src/gooddata_eval/core/timing.py b/packages/gooddata-eval/src/gooddata_eval/core/timing.py index a3c05aef1..b2d203d7b 100644 --- a/packages/gooddata-eval/src/gooddata_eval/core/timing.py +++ b/packages/gooddata-eval/src/gooddata_eval/core/timing.py @@ -8,6 +8,9 @@ from __future__ import annotations +import math +from collections.abc import Iterable, Sequence +from contextvars import ContextVar, Token from dataclasses import dataclass from gooddata_eval.core._output import emit_line @@ -72,3 +75,110 @@ 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) + + +# 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. ``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), + } + + +# The response time of every chat turn the current item completed, in order. gen-ai opens one +# trace per chat message, so these are per-trace latencies: the time from sending the message +# to the end of its stream, with none of the judge, simulated-user or conversation set-up time +# that a whole-run figure carries. `ChatClient.send_message` is the only writer; recording it +# there means no evaluator has to remember to, and a new one is covered without an edit. +_trace_latencies: ContextVar[list[float] | None] = ContextVar("gd_eval_trace_latencies", default=None) + + +class TraceRecorder: + """Collects the per-trace latencies of whatever runs between ``__init__`` and ``stop``. + + Scoped to the calling thread's context, so items evaluated concurrently each get their own + list. Create it where the item starts and call ``stop`` in a ``finally``. + """ + + def __init__(self) -> None: + self.latencies: list[float] = [] + self._token: Token[list[float] | None] | None = _trace_latencies.set(self.latencies) + + def stop(self) -> list[float]: + """Detach from the context and return what was recorded. Safe to call twice.""" + if self._token is not None: + _trace_latencies.reset(self._token) + 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. + + A no-op without a recorder (a library caller, or a test) and for a turn that carries no + timing, so the caller never has to check either. + """ + sink = _trace_latencies.get() + if sink is not None and seconds is not None: + sink.append(seconds) 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 b7101ef37..80aa96692 100644 --- a/packages/gooddata-eval/tests/test_agentic_dashboard_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_dashboard_skill.py @@ -1891,6 +1891,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 @@ -1940,6 +1941,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_document_skill.py b/packages/gooddata-eval/tests/test_agentic_document_skill.py index baa60b1de..375a02b9a 100644 --- a/packages/gooddata-eval/tests/test_agentic_document_skill.py +++ b/packages/gooddata-eval/tests/test_agentic_document_skill.py @@ -530,6 +530,8 @@ def test_a_passing_item_returns_its_checks_and_draft_facts(monkeypatch: pytest.M assert outcome.detail["document_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: @@ -539,6 +541,7 @@ def test_a_failing_item_raises_with_the_failures(monkeypatch: pytest.MonkeyPatch assert "the agent never produced a successful draft_document 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_document_builder_points_at_the_flags(monkeypatch: pytest.MonkeyPatch) -> None: @@ -873,8 +876,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_document_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_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_runner.py b/packages/gooddata-eval/tests/test_agentic_runner.py index 9a055f450..f331bb11d 100644 --- a/packages/gooddata-eval/tests/test_agentic_runner.py +++ b/packages/gooddata-eval/tests/test_agentic_runner.py @@ -21,7 +21,7 @@ from gooddata_eval.core.agentic.alert_skill import AlertSkillAssertionError from gooddata_eval.core.evaluators._llm_judge import JudgeResponseError from gooddata_eval.core.models import AgenticEvalOutcome, DatasetItem -from gooddata_eval.core.timing import PhaseTimings +from gooddata_eval.core.timing import PhaseTimings, note_trace_latency def test_dispatch_agentic_passes_agent_id_through_to_alert_skill(): @@ -970,6 +970,155 @@ def test_run_agentic_items_surfaces_failed_runs_on_a_partial_pass(): 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.by_mode["multi_turn"]["runs_s"] 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] + + +# --- per-trace latency: collected from the chat client, not handed out by each evaluator --- + + +def _noting(*latencies: float, then: Any = None): + """A stand-in evaluator that completes the given chat turns, as ChatClient.send_message would.""" + + def evaluate(*args: Any, **kwargs: Any) -> Any: + for latency in latencies: + note_trace_latency(latency) + if isinstance(then, BaseException): + raise then + return then if then is not None else AgenticEvalOutcome(reasoning_steps=[], detail={}) + + return evaluate + + +@pytest.mark.parametrize(("kind", "expected_output", "target"), _ALL_AGENTIC_KIND_CASES) +def test_every_kind_gets_its_chat_turns_recorded_as_trace_latencies(kind, expected_output, target): + """The recorder wraps the item, so a kind needs no code of its own to be covered -- which is + the point: nothing a new evaluator forgets can drop it out of the per-trace percentiles.""" + item = DatasetItem(id="q1", dataset_name="ds", test_kind=kind, question="q", expected_output=expected_output) + with patch(f"gooddata_eval.cli.agentic_runner.{target}", side_effect=_noting(1.5, 2.5, 0.5)): + report = run_agentic_items([item], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") + assert report.items[0].trace_latencies_s == [1.5, 2.5, 0.5] + + +def test_turns_completed_before_a_failed_gate_still_count_as_traces(): + exc = AlertSkillAssertionError("nope") + exc.reasoning_steps = [] + exc.detail = {} + with patch( + "gooddata_eval.cli.agentic_runner.evaluate_agentic_alert_skill", side_effect=_noting(3.0, 4.0, then=exc) + ): + report = run_agentic_items([_item()], host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01") + assert report.items[0].trace_latencies_s == [3.0, 4.0] + + +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, 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") + 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] + + +def test_concurrent_items_each_keep_only_their_own_trace_latencies(): + items = [ + DatasetItem( + id=f"g{n}", dataset_name="d", test_kind="agentic_general_question", question=f"q{n}", expected_output="x" + ) + for n in range(1, 5) + ] + + def evaluate(*args: Any, **kwargs: Any) -> AgenticEvalOutcome: + base = float(kwargs["question"][1:]) * 100 + for step in range(3): + time.sleep(0.005) # interleave with the other workers + note_trace_latency(base + step) + return AgenticEvalOutcome(reasoning_steps=[], detail={}) + + with patch("gooddata_eval.cli.agentic_runner.evaluate_agentic_general_question", side_effect=evaluate): + report = run_agentic_items( + items, host="http://host", token="tok", workspace_id="ws1", run_ts="2026-01-01", concurrency=4 + ) + assert {i.id: i.trace_latencies_s for i in report.items} == { + f"g{n}": [n * 100.0, n * 100.0 + 1, n * 100.0 + 2] for n in range(1, 5) + } + + @pytest.mark.parametrize(("tags", "expected_extra"), [((), None), (("acme", "nightly"), {"tags": ["acme", "nightly"]})]) def test_run_agentic_items_passes_custom_tags_as_run_metadata(tags, expected_extra): with patch( 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..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 @@ -200,3 +202,378 @@ 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() + + +_TRACES = {"n": 4, "mean": 2.5, "min": 1.0, "p50": 2.5, "p70": 3.1, "p90": 3.7, "p99": 3.97, "max": 4.0} +_RUNS = {"n": 2, "mean": 20.0, "min": 10.0, "p50": 20.0, "p70": 24.0, "p90": 28.0, "p99": 29.8, "max": 30.0} +_BY_MODE = { + "single_shot": {"total": 3, "passed": 2, "traces_s": _TRACES}, + "multi_turn": {"total": 1, "passed": 1, "traces_s": _TRACES, "runs_s": _RUNS}, +} + + +def _doc_with_modes(model: str) -> dict: + doc = _doc(model, True) + doc["runs"][model]["summary"]["by_mode"] = _BY_MODE + doc["runs"][model]["items"]["item-1"].update({"mode": "single_shot", "trace_latencies_s": [1.0, 2.0]}) + doc["comparison"][model]["by_mode"] = _BY_MODE + return doc + + +def test_html_carries_the_mode_split_it_was_built_from(): + data = _embedded(build_html(_doc_with_modes("gpt-5"))) + + assert data["runs"]["gpt-5"]["summary"]["by_mode"] == _BY_MODE + assert data["comparison"]["gpt-5"]["by_mode"] == _BY_MODE + item = data["runs"]["gpt-5"]["items"]["item-1"] + assert (item["mode"], item["trace_latencies_s"]) == ("single_shot", [1.0, 2.0]) + + +def test_redact_keeps_the_mode_split_and_latency(): + """Latency and the section an item falls in are facts about the run, not about our + infrastructure -- the same line redaction already draws for per-item latency.""" + data = _embedded(build_html(_doc_with_modes("gpt-5"), redact=True)) + + assert data["runs"]["Model A"]["summary"]["by_mode"] == _BY_MODE + assert data["comparison"]["Model A"]["by_mode"] == _BY_MODE + assert data["runs"]["Model A"]["items"]["item-1"]["trace_latencies_s"] == [1.0, 2.0] + + +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-question tests · response time", + "Conversation tests · response time", + "Per response", + "Per conversation", + ): + assert text in html + 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] + assert "sort(" not in renderers + assert "percentile(" not in renderers + + +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 ( + "Response time", + "Time per attempt", + "trace_latencies_s", + "run_latencies_s", + "'lt:' + l", + "'lr:' + l", + ): + assert text in html + 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="latency"', 'id="items"'): + assert bar < html.index(section), f"the filter bar must come before {section}" + # 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()", "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_kind_status_and_run_are_multi_selects_in_both_tiers(): + html = build_html(_doc_with_modes("gpt-5")) + + for facet in ("kind", "status", "runs"): + assert f"multi('{facet}'" in html + assert 'type="checkbox"' in html + # The old single-value selects and the single "focused run" are gone. + for old in ('