Skip to content

Commit 99e5936

Browse files
Merge pull request #1650 from gooddata/snapshot-master-73a34c64-to-rel/dev
[bot] Merge master/73a34c64 into rel/dev
2 parents 36df9b7 + 73a34c6 commit 99e5936

9 files changed

Lines changed: 3466 additions & 11 deletions

File tree

packages/gooddata-eval/LICENSE.txt

Lines changed: 3252 additions & 0 deletions
Large diffs are not rendered by default.

packages/gooddata-eval/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Both provider name and provider id are accepted as the prefix.
9191
| Flag | Default | Description |
9292
|---|---|---|
9393
| `--runs K` | `2` | Independent runs per item (pass@K). An item passes if any run passes. |
94+
| `--concurrency K` | `1` | Number of items evaluated concurrently. `1` = sequential (default). Increase to load-test the agent under simultaneous requests. Progress output interleaves when K > 1. |
9495

9596
#### Output
9697

packages/gooddata-eval/src/gooddata_eval/cli/main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ def _build_parser() -> argparse.ArgumentParser:
5252
),
5353
)
5454
run.add_argument("--runs", type=int, default=2, help="Independent runs per item (pass@K). Default 2.")
55+
run.add_argument(
56+
"--concurrency",
57+
type=int,
58+
default=1,
59+
help="Number of items evaluated concurrently (default 1 = sequential). "
60+
"Increase to load-test the agent under simultaneous requests.",
61+
)
5562
run.add_argument("--json", dest="json_path", help="Write a JSON report to this path.")
5663
run.add_argument("--quiet", action="store_true", help="Suppress per-item progress output.")
5764
run.add_argument(
@@ -270,6 +277,7 @@ def on_langfuse_item_done(
270277
on_run_done=on_run_done,
271278
on_item_done=on_item_done,
272279
on_langfuse_item_done=on_langfuse_item_done,
280+
concurrency=config.concurrency,
273281
)
274282
finally:
275283
if hasattr(backend, "close"):
@@ -309,6 +317,9 @@ def on_langfuse_item_done(
309317

310318
def main(argv: list[str] | None = None) -> int:
311319
args = parse_args(argv if argv is not None else sys.argv[1:])
320+
if hasattr(args, "concurrency") and args.concurrency < 1:
321+
print("error: --concurrency must be >= 1.", file=sys.stderr)
322+
return _EXIT_OPERATIONAL_ERROR
312323
try:
313324
host, token = resolve_connection(host=args.host, token=args.token, profile=args.profile)
314325
if args.command == "models":
@@ -321,6 +332,7 @@ def main(argv: list[str] | None = None) -> int:
321332
langfuse_dataset=args.langfuse_dataset,
322333
models=args.models or [],
323334
runs=args.runs,
335+
concurrency=args.concurrency,
324336
json_path=Path(args.json_path) if args.json_path else None,
325337
log_to_langfuse=args.langfuse,
326338
quiet=args.quiet,

packages/gooddata-eval/src/gooddata_eval/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class RunConfig:
1414
langfuse_dataset: str | None = None
1515
models: list[str] = field(default_factory=list)
1616
runs: int = 2
17+
concurrency: int = 1
1718
json_path: Path | None = None
1819
log_to_langfuse: bool = False
1920
quiet: bool = False

packages/gooddata-eval/src/gooddata_eval/core/reporting/console.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,16 @@ def render_console(report: EvalReport, *, console: Console | None = None) -> str
4646
table.add_row(item.id, item.test_kind, result, str(item.runs), latency, avg, quality, notes)
4747

4848
out.print(table)
49+
_wall = report.wall_clock_s
50+
_agent = report.latency_s
51+
if _wall > 0 and abs(_wall - _agent) > 1: # concurrency > 1: show both
52+
timing = f"{_wall:.2f}s wall-clock, {_agent:.2f}s agent time (avg {report.avg_latency_s:.2f}s/run)"
53+
else:
54+
timing = f"{_agent:.2f}s (avg {report.avg_latency_s:.2f}s/run)"
4955
out.print(
5056
f"\nSummary: {report.passed}/{report.total} passed "
5157
f"({report.skipped} skipped, {report.errored} errored) "
52-
f"avg quality {report.avg_quality_score:.0%} "
53-
f"in {report.latency_s:.2f}s (avg {report.avg_latency_s:.2f}s/run)"
58+
f"avg quality {report.avg_quality_score:.0%} in {timing}"
5459
)
5560
return out.export_text() if out.record else ""
5661

packages/gooddata-eval/src/gooddata_eval/core/reporting/json_report.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def _build_run_dict(report: EvalReport) -> dict:
2020
"errored": report.errored,
2121
"latency_s": round(report.latency_s, 3),
2222
"avg_latency_s": round(report.avg_latency_s, 3),
23+
"wall_clock_s": round(report.wall_clock_s, 3),
2324
},
2425
"items": {
2526
item.id: {

packages/gooddata-eval/src/gooddata_eval/core/runner.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"""Dataset run orchestration: per item, K single-turn runs, route by test_kind, aggregate pass@K."""
33

44
import time
5+
import traceback
6+
from concurrent.futures import ThreadPoolExecutor, as_completed
57
from dataclasses import dataclass, field
68
from functools import partial
79
from typing import Callable, Protocol
@@ -52,6 +54,7 @@ class EvalReport:
5254
provider_type: str = ""
5355
workspace_id: str = ""
5456
items: list[ItemReport] = field(default_factory=list)
57+
wall_clock_s: float = 0.0 # actual elapsed time; differs from latency_s under concurrency
5558

5659
@property
5760
def total(self) -> int:
@@ -153,6 +156,7 @@ def run_items(
153156
on_run_done: Callable[[int, int, int, int, bool, float], None] | None = None,
154157
on_item_done: Callable[[int, int, ItemReport], None] | None = None,
155158
on_langfuse_item_done: Callable[[int, int, ItemReport], None] | None = None,
159+
concurrency: int = 1,
156160
) -> EvalReport:
157161
"""Run every item K times, routing by test_kind, and aggregate pass@K.
158162
@@ -162,19 +166,47 @@ def run_items(
162166
- on_run_done(index, total, run_index, runs, passed, latency) after each individual run
163167
- on_item_done(index, total, report) after an item is fully evaluated
164168
- on_langfuse_item_done(index, total, report) after non-skipped, non-errored items only
169+
170+
concurrency > 1 dispatches items to a ThreadPoolExecutor so multiple
171+
questions are sent to the agent simultaneously. Each item still runs
172+
--runs times sequentially (pass@K). Results are collected in input order.
165173
"""
174+
concurrency = max(1, concurrency)
166175
report = EvalReport(
167176
model=model, provider_name=provider_name, provider_type=provider_type, workspace_id=workspace_id
168177
)
169178
total = len(items)
170-
for index, item in enumerate(items, start=1):
171-
if on_item_start is not None:
172-
on_item_start(index, total, item)
179+
180+
def _process_item(index: int, item: DatasetItem) -> ItemReport:
181+
try:
182+
if on_item_start is not None:
183+
on_item_start(index, total, item)
184+
except Exception: # non-fatal — callback must not abort a parallel run
185+
traceback.print_exc()
173186
run_cb = partial(_forward_run_event, on_run_done, index, total) if on_run_done is not None else None
174187
item_report = _run_one_item(item, backend, runs, on_run_done=run_cb)
175-
report.items.append(item_report)
176-
if on_item_done is not None:
177-
on_item_done(index, total, item_report)
178-
if on_langfuse_item_done is not None and not item_report.skipped and item_report.error is None:
179-
on_langfuse_item_done(index, total, item_report)
188+
try:
189+
if on_item_done is not None:
190+
on_item_done(index, total, item_report)
191+
if on_langfuse_item_done is not None and not item_report.skipped and item_report.error is None:
192+
on_langfuse_item_done(index, total, item_report)
193+
except Exception: # non-fatal — log but don't abort
194+
traceback.print_exc()
195+
return item_report
196+
197+
_t0 = time.perf_counter()
198+
if concurrency <= 1:
199+
for index, item in enumerate(items, start=1):
200+
report.items.append(_process_item(index, item))
201+
else:
202+
# Dispatch concurrently; collect in original order.
203+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
204+
futures = {pool.submit(_process_item, index, item): index for index, item in enumerate(items, start=1)}
205+
results: dict[int, ItemReport] = {}
206+
for future in as_completed(futures):
207+
idx = futures[future]
208+
results[idx] = future.result()
209+
for index in range(1, total + 1):
210+
report.items.append(results[index])
211+
report.wall_clock_s = time.perf_counter() - _t0
180212
return report

packages/gooddata-eval/tests/test_cli.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,43 @@ def test_parse_model_arg_plain_model_no_strip():
490490
# The no-slash path does not strip whitespace; argparse never passes
491491
# whitespace through, so this documents the current behaviour.
492492
assert _parse_model_arg(" gpt-5.2 ") == (None, " gpt-5.2 ")
493+
494+
495+
def test_cli_rejects_zero_concurrency(monkeypatch, fixtures_dir):
496+
monkeypatch.setattr(cli_main, "resolve_connection", lambda host, token, profile: ("https://h", "tok"))
497+
exit_code = cli_main.main(
498+
[
499+
"run",
500+
"--host",
501+
"https://h",
502+
"--token",
503+
"tok",
504+
"--workspace",
505+
"ws1",
506+
"--dataset",
507+
str(fixtures_dir / "sample_dataset"),
508+
"--concurrency",
509+
"0",
510+
]
511+
)
512+
assert exit_code == 2
513+
514+
515+
def test_cli_rejects_negative_concurrency(monkeypatch, fixtures_dir):
516+
monkeypatch.setattr(cli_main, "resolve_connection", lambda host, token, profile: ("https://h", "tok"))
517+
exit_code = cli_main.main(
518+
[
519+
"run",
520+
"--host",
521+
"https://h",
522+
"--token",
523+
"tok",
524+
"--workspace",
525+
"ws1",
526+
"--dataset",
527+
str(fixtures_dir / "sample_dataset"),
528+
"--concurrency",
529+
"-1",
530+
]
531+
)
532+
assert exit_code == 2

packages/gooddata-eval/tests/test_runner.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# (C) 2026 GoodData Corporation
2+
import threading
3+
24
from gooddata_eval.core.evaluators import supported_test_kinds
35
from gooddata_eval.core.models import ChatResult, DatasetItem
4-
from gooddata_eval.core.runner import run_items
6+
from gooddata_eval.core.runner import ItemReport, run_items
57

68

79
def _viz_obj():
@@ -135,3 +137,112 @@ def test_run_items_does_not_invoke_langfuse_callback_for_skipped_items():
135137
on_langfuse_item_done=lambda idx, total, r: langfuse_calls.append(r.id),
136138
)
137139
assert langfuse_calls == []
140+
141+
142+
def test_run_items_concurrency_produces_all_results_in_order():
143+
"""With concurrency > 1 all items still appear in input order."""
144+
items = [
145+
DatasetItem(
146+
id=f"item-{i}",
147+
dataset_name="d",
148+
test_kind="visualization",
149+
question=f"q{i}",
150+
expected_output={"visualization": _viz_obj()},
151+
)
152+
for i in range(6)
153+
]
154+
backend = _FakeBackend([_chat_with(_viz_obj())] * 6)
155+
report = run_items(items, backend, runs=1, concurrency=3)
156+
assert report.total == 6
157+
assert [r.id for r in report.items] == [f"item-{i}" for i in range(6)]
158+
assert all(r.pass_at_k for r in report.items)
159+
160+
161+
def test_run_items_concurrency_1_and_sequential_produce_same_results():
162+
"""concurrency=1 and the default sequential path give identical reports."""
163+
items = [
164+
DatasetItem(
165+
id=f"i{i}",
166+
dataset_name="d",
167+
test_kind="visualization",
168+
question="q",
169+
expected_output={"visualization": _viz_obj()},
170+
)
171+
for i in range(4)
172+
]
173+
backend_a = _FakeBackend([_chat_with(_viz_obj())] * 4)
174+
backend_b = _FakeBackend([_chat_with(_viz_obj())] * 4)
175+
report_seq = run_items(items, backend_a, runs=1)
176+
report_par = run_items(items, backend_b, runs=1, concurrency=1)
177+
assert [r.id for r in report_seq.items] == [r.id for r in report_par.items]
178+
assert [r.pass_at_k for r in report_seq.items] == [r.pass_at_k for r in report_par.items]
179+
180+
181+
def test_run_items_concurrency_errored_item_does_not_crash_pool():
182+
"""An errored item is recorded but does not abort a concurrent run."""
183+
184+
class _BoomBackend:
185+
def ask(self, question: str) -> ChatResult:
186+
raise RuntimeError("agent down")
187+
188+
items = [
189+
DatasetItem(
190+
id=f"e{i}",
191+
dataset_name="d",
192+
test_kind="visualization",
193+
question="q",
194+
expected_output={"visualization": _viz_obj()},
195+
)
196+
for i in range(4)
197+
]
198+
report = run_items(items, _BoomBackend(), runs=1, concurrency=3)
199+
assert report.total == 4
200+
assert report.errored == 4
201+
assert [r.id for r in report.items] == [f"e{i}" for i in range(4)]
202+
203+
204+
def test_run_items_concurrency_callbacks_fire_for_all_items():
205+
"""on_item_done is called exactly once per item under concurrency > 1."""
206+
backend = _FakeBackend([_chat_with(_viz_obj())] * 5)
207+
lock = threading.Lock()
208+
done_ids: list = []
209+
210+
def on_done(index: int, total: int, report: ItemReport) -> None:
211+
with lock:
212+
done_ids.append(report.id)
213+
214+
items = [
215+
DatasetItem(
216+
id=f"c{i}",
217+
dataset_name="d",
218+
test_kind="visualization",
219+
question="q",
220+
expected_output={"visualization": _viz_obj()},
221+
)
222+
for i in range(5)
223+
]
224+
run_items(items, backend, runs=1, concurrency=3, on_item_done=on_done)
225+
assert sorted(done_ids) == [f"c{i}" for i in range(5)]
226+
227+
228+
def test_run_items_callback_exception_is_logged_not_swallowed(capsys):
229+
"""A raising callback prints a traceback to stderr but the run continues."""
230+
backend = _FakeBackend([_chat_with(_viz_obj())] * 2)
231+
items = [
232+
DatasetItem(
233+
id=f"x{i}",
234+
dataset_name="d",
235+
test_kind="visualization",
236+
question="q",
237+
expected_output={"visualization": _viz_obj()},
238+
)
239+
for i in range(2)
240+
]
241+
242+
def bad_callback(index, total, report):
243+
raise RuntimeError("callback bug")
244+
245+
result = run_items(items, backend, runs=1, on_item_done=bad_callback)
246+
assert result.total == 2 # run did not abort
247+
err = capsys.readouterr().err
248+
assert "RuntimeError" in err or "callback bug" in err # traceback was printed

0 commit comments

Comments
 (0)