Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
86e3bfe
feat(gooddata-eval): record per-run latency and report percentiles
Oct 11, 2026
0f26389
feat(gooddata-eval): restyle HTML report, show latency percentiles
Oct 11, 2026
424ff81
feat(gooddata-eval): split latency by single-shot vs multi-turn
Oct 11, 2026
78964f4
feat(gooddata-eval): label item latency, multi-select kind filter
Oct 11, 2026
65e1d33
feat(gooddata-eval): filters on top drive the whole HTML report
Oct 11, 2026
df9d019
feat(gooddata-eval): two filter tiers, multi-select status and run
Oct 11, 2026
f5127a7
feat(gooddata-eval): add a trimmed mean to the latency distributions
Oct 11, 2026
10d12a4
feat(gooddata-eval): redesign the HTML report as a readable document
Oct 12, 2026
a3d8db6
fix(gooddata-eval): count timed-out turns and fix HTML change labels
Oct 12, 2026
67dba79
feat(gooddata-eval): add a By model view and outcomes table to the HT…
Oct 14, 2026
d79af09
fix(gooddata-eval): draw the HTML report sparkline dots as true circles
Oct 15, 2026
0900b3d
feat(gooddata-eval): show plain model positions in the quality-agains…
Oct 15, 2026
a382984
feat(gooddata-eval): open the HTML report by date and order its runs …
Oct 16, 2026
fc90a8e
feat(gooddata-eval): update HTML report to include run date and name …
Oct 16, 2026
43ce94a
feat: add started_at timestamp to EvalReport and related tests
Oct 16, 2026
eb09ae2
feat(gooddata-eval): render markdown titles as bold in reasoning sect…
Oct 16, 2026
91d3e78
feat(gooddata-eval): add legend for sparkline dots to clarify compari…
Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 182 additions & 16 deletions packages/gooddata-eval/README.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from typing import Any, TypedDict

from gooddata_eval.core.agentic._gate import DEFAULT_GATE, EvalGate, normalize_gate
Expand All @@ -28,6 +29,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):
Expand Down Expand Up @@ -375,6 +377,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.

Expand Down Expand Up @@ -429,6 +443,7 @@ def run_agentic_items(
# latency. Drained below before this function returns, so every score is written
# before the caller renders a report or decides an exit code.
linker = BackgroundTraceLinker()
report.started_at = datetime.now(timezone.utc)
_t0 = time.perf_counter()

def _process_item(index: int, item: DatasetItem) -> ItemReport:
Expand All @@ -447,6 +462,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(
Expand Down Expand Up @@ -482,6 +501,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
Expand All @@ -492,6 +512,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.
Expand All @@ -512,6 +533,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.
Expand All @@ -525,8 +547,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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: on the generic except Exception branch, finally still keeps the trace latencies collected so far, but the item gets runs = 0 and no run_latencies_s. A multi-turn item that completes 2 of 3 runs and then raises keeps its turns in multi_turn.traces_s but contributes nothing to runs_s. The per-response and per-conversation tables then cover different item sets, and the row shows a response time with – for time per attempt.

Either apply _apply_run_latencies here too (if the exception carries them) or drop the traces for items that report no runs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7bc56fa, by dropping the traces rather than applying the run latencies.

Applying _apply_run_latencies here wouldn't fix the case you describe: an exception raised in the middle of run 3 doesn't carry the times of runs 1 and 2, only the assertion and judge errors do, and those are handled by the branches above. So the generic branch now calls traces.discard() (new TraceRecorder.discard), and an item that reports no runs contributes to neither the per-response nor the per-conversation table.

I replaced the test that deliberately kept those traces. An ungraded item (judge fault) still keeps both, since its run times are on the exception, and there's a test for that too.


if on_item_done is not None:
try:
Expand Down
4 changes: 4 additions & 0 deletions packages/gooddata-eval/src/gooddata_eval/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,10 @@ def on_langfuse_item_done(
report.items.extend(agentic_report.items)
report.items.extend(single_report.items)
report.wall_clock_s = (agentic_report.wall_clock_s if agentic_report else 0.0) + single_report.wall_clock_s
report.started_at = min(
(r.started_at for r in (agentic_report, single_report) if r is not None and r.started_at is not None),
default=None,
)

skipped_kinds = sorted({i.test_kind for i in report.items if i.skipped})
if skipped_kinds:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import os
import re
import time
from dataclasses import dataclass, field
from typing import Any

Expand Down Expand Up @@ -587,6 +588,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
Expand Down Expand Up @@ -802,6 +805,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:
Expand Down Expand Up @@ -903,6 +907,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:
Expand Down Expand Up @@ -1122,6 +1127,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,
Expand All @@ -1133,4 +1139,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],
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import logging
import os
import re
import time
from dataclasses import dataclass, field
from typing import Any

Expand Down Expand Up @@ -284,6 +285,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
Expand Down Expand Up @@ -393,6 +396,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)
Expand Down Expand Up @@ -440,6 +444,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:
Expand Down Expand Up @@ -651,6 +656,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(
Expand All @@ -661,4 +667,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],
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -612,6 +613,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]]:
Expand Down Expand Up @@ -771,6 +775,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:
Expand All @@ -779,10 +784,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
Expand Down Expand Up @@ -1006,6 +1015,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)
Expand Down Expand Up @@ -1039,6 +1050,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,
)


Expand Down Expand Up @@ -1246,6 +1258,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,
Expand All @@ -1254,4 +1267,5 @@ def _write_scores(ctx: RunTraceContext) -> None:
detail=detail,
runs_passed=1,
runs_effective=1,
run_latencies_s=[result.run_latency_s],
)
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,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
Expand Down Expand Up @@ -1399,6 +1401,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()
Expand Down Expand Up @@ -1475,6 +1478,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,
)


Expand Down Expand Up @@ -1703,6 +1707,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,
Expand All @@ -1713,4 +1718,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],
)
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,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]:
Expand Down Expand Up @@ -711,6 +713,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()
Expand Down Expand Up @@ -781,6 +784,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,
)


Expand Down Expand Up @@ -980,6 +984,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)
Expand Down Expand Up @@ -1033,6 +1038,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,
Expand All @@ -1043,4 +1049,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],
)
Loading
Loading