Skip to content

Commit 23092e1

Browse files
committed
feat(gooddata-eval): make the K verdict a gate, pass@K or pass^K
1 parent 5b04a0a commit 23092e1

22 files changed

Lines changed: 517 additions & 62 deletions

‎packages/gooddata-eval/README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ Both provider name and provider id are accepted as the prefix.
9090

9191
| Flag | Default | Description |
9292
|---|---|---|
93-
| `--runs K` | `2` | Independent runs per item (pass@K). An item passes if any run passes. |
93+
| `--runs K` | `2` | Independent runs per item. |
94+
| `--gate` | `any` | Which verdict decides an item: `any` = pass@K (a run passing is enough), `power` = pass^K (every run must pass, so the verdict measures stability). Identical at `--runs 1`. Agentic kinds only. |
9495
| `--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. |
9596
| `--reasoning-effort LEVEL` | server default | `LOW`, `MEDIUM` or `HIGH`, sent as `options.reasoningEffort` on every chat message. Requires the `enableGenAiReasoningEffort` feature flag on the target organization — without it the server ignores the value. Applies to chat items only; `dashboard_summary` items go through the summary endpoint, which has no such option. |
9697

@@ -241,8 +242,14 @@ Without `[llm-judge]`, those items are **skipped**.
241242

242243
| Score | Description |
243244
|---|---|
244-
| `pass_at_k` | 1 if any of the K runs passed strict checks, else 0. |
245+
| `pass_at_k` | 1 if **any** of the K runs passed strict checks, else 0. |
246+
| `pass_power_k` | 1 only if **every** one of the K runs passed. Agentic kinds only. |
247+
| `gate_passed` | The verdict that decided the item: `pass_at_k` under `--gate any`, `pass_power_k` under `--gate power`. Agentic kinds only. |
245248
| `quality_score` | Fraction of strict check flags that are `True` (0.0–1.0). Shown in CLI as a percentage. |
246249
| `value_score` | Weighted blend: 0.6 × quality + 0.2 × speed (speed = max(0, 1 − latency/60s)). |
247250
| `latency_s` | Average per-run latency in seconds. |
248251
| `provider_type` | Model vendor + gateway label (e.g. `ANTHROPIC`, `BEDROCK/ANTHROPIC`, `AZURE/OPENAI`). Stored in Langfuse trace metadata and tags. |
252+
253+
Score names carry no K; K and the gate are on the dataset-run metadata as `eval_k` and
254+
`eval_gate`. `agentic_visualization` also still writes its historic `pass_at_{K}` /
255+
`pass_power_{K}` pair. `agentic_conversation` has no K gate.

‎packages/gooddata-eval/src/gooddata_eval/cli/agentic_runner.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
from typing import Any, TypedDict
88

9+
from gooddata_eval.core.agentic._gate import DEFAULT_GATE, EvalGate, normalize_gate
910
from gooddata_eval.core.agentic._langfuse import make_langfuse_client
1011
from gooddata_eval.core.agentic.alert_skill import evaluate_agentic_alert_skill
1112
from gooddata_eval.core.agentic.conversation import ConversationFixture, evaluate_agentic_conversation
@@ -85,8 +86,12 @@ def _dispatch_agentic(
8586
run_ts: str,
8687
model_version_override: str | None,
8788
reasoning_effort: ReasoningEffort | None = None,
89+
gate: EvalGate = DEFAULT_GATE,
8890
) -> None:
89-
"""Call the appropriate evaluate_agentic_* function for the item's test_kind."""
91+
"""Call the appropriate evaluate_agentic_* function for the item's test_kind.
92+
93+
`gate` reaches every kind except agentic_conversation, which has no K to gate over.
94+
"""
9095
kind = item.test_kind
9196
eo = item.expected_output
9297
lf_kw: _LfKw = {
@@ -106,6 +111,7 @@ def _dispatch_agentic(
106111
question=item.question,
107112
expected_outputs=_parse_visualization_expected(eo),
108113
k=k,
114+
gate=gate,
109115
**lf_kw,
110116
)
111117
elif kind == "agentic_metric_skill":
@@ -116,6 +122,7 @@ def _dispatch_agentic(
116122
question=item.question,
117123
expected_output=eo if isinstance(eo, (dict, list)) else {},
118124
k=k,
125+
gate=gate,
119126
**lf_kw,
120127
)
121128
elif kind == "agentic_alert_skill":
@@ -126,6 +133,7 @@ def _dispatch_agentic(
126133
question=item.question,
127134
expected_output=eo if isinstance(eo, dict) else {},
128135
k=k,
136+
gate=gate,
129137
**lf_kw,
130138
)
131139
elif kind == "agentic_search":
@@ -139,6 +147,7 @@ def _dispatch_agentic(
139147
question=item.question,
140148
expected_tool_call=expected_args,
141149
k=k,
150+
gate=gate,
142151
**lf_kw,
143152
)
144153
elif kind == "agentic_general_question":
@@ -149,6 +158,7 @@ def _dispatch_agentic(
149158
question=item.question,
150159
expected_output=eo if isinstance(eo, str) else str(eo),
151160
k=k,
161+
gate=gate,
152162
**lf_kw,
153163
)
154164
elif kind == "agentic_guardrail":
@@ -159,6 +169,7 @@ def _dispatch_agentic(
159169
question=item.question,
160170
expected_output=eo if isinstance(eo, str) else str(eo),
161171
k=k,
172+
gate=gate,
162173
**lf_kw,
163174
)
164175
elif kind == "agentic_kda_skill":
@@ -169,6 +180,7 @@ def _dispatch_agentic(
169180
question=item.question,
170181
expected_output=eo if isinstance(eo, dict) else {},
171182
k=k,
183+
gate=gate,
172184
**lf_kw,
173185
)
174186
elif kind == "agentic_conversation":
@@ -193,6 +205,7 @@ def run_agentic_items(
193205
k: int = 2,
194206
model_version: str | None = None,
195207
reasoning_effort: ReasoningEffort | None = None,
208+
gate: EvalGate = DEFAULT_GATE,
196209
use_langfuse: bool = False,
197210
run_ts: str,
198211
on_item_start: Any = None,
@@ -201,7 +214,7 @@ def run_agentic_items(
201214
"""Run agentic items through evaluate_agentic_* and return an EvalReport."""
202215
langfuse = make_langfuse_client() if use_langfuse else None
203216

204-
report = EvalReport(model=model_version)
217+
report = EvalReport(model=model_version, gate=normalize_gate(gate))
205218
total = len(items)
206219

207220
for index, item in enumerate(items, start=1):
@@ -219,11 +232,13 @@ def run_agentic_items(
219232
)
220233
t0 = time.perf_counter()
221234
try:
222-
_dispatch_agentic(item, host, token, workspace_id, k, langfuse, run_ts, model_version, reasoning_effort)
223-
item_report.pass_at_k = True
235+
_dispatch_agentic(
236+
item, host, token, workspace_id, k, langfuse, run_ts, model_version, reasoning_effort, gate
237+
)
238+
item_report.gate_passed = True
224239
item_report.runs = k
225240
except AssertionError as exc:
226-
item_report.pass_at_k = False
241+
item_report.gate_passed = False
227242
item_report.runs = k
228243
print(f"[agentic] {item.id} FAIL: {exc}", flush=True)
229244
except Exception as exc:

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from gooddata_eval.cli.agentic_runner import AGENTIC_TEST_KINDS, run_agentic_items
1717
from gooddata_eval.core.chat.sse_client import ChatClient
18-
from gooddata_eval.core.config import ReasoningEffort, RunConfig
18+
from gooddata_eval.core.config import DEFAULT_GATE, EvalGate, ReasoningEffort, RunConfig, normalize_gate
1919
from gooddata_eval.core.connection import ConnectionError_, resolve_connection
2020
from gooddata_eval.core.dataset.local import load_local_dataset
2121
from gooddata_eval.core.langfuse.sink import LangfuseSink
@@ -89,7 +89,15 @@ def _build_parser() -> argparse.ArgumentParser:
8989
"Default: workspace's current active model."
9090
),
9191
)
92-
run.add_argument("--runs", type=int, default=2, help="Independent runs per item (pass@K). Default 2.")
92+
run.add_argument("--runs", type=int, default=2, help="Independent runs per item. Default 2.")
93+
run.add_argument(
94+
"--gate",
95+
choices=get_args(EvalGate),
96+
default=DEFAULT_GATE,
97+
help="Which verdict decides an item: 'any' = pass@K (a run passing is enough, the "
98+
"default and historic behaviour), 'power' = pass^K (every run must pass, so the verdict "
99+
"measures stability). Identical at --runs 1. Agentic kinds only.",
100+
)
93101
run.add_argument(
94102
"--concurrency",
95103
type=int,
@@ -178,7 +186,7 @@ def on_item_done(index: int, total: int, report: ItemReport) -> None:
178186
tag = "[yellow]SKIP[/yellow]"
179187
elif report.error:
180188
tag = "[red]ERR [/red]"
181-
elif report.pass_at_k:
189+
elif report.gate_passed:
182190
tag = "[green]PASS[/green]"
183191
else:
184192
tag = "[red]FAIL[/red]"
@@ -341,6 +349,7 @@ def on_langfuse_item_done(
341349
token=config.token,
342350
workspace_id=config.workspace_id,
343351
k=config.runs,
352+
gate=config.gate,
344353
model_version=resolved.model_id,
345354
reasoning_effort=config.reasoning_effort,
346355
use_langfuse=config.log_to_langfuse,
@@ -449,6 +458,7 @@ def main(argv: list[str] | None = None) -> int:
449458
kind=args.kind,
450459
preserve_failed=args.preserve_failed,
451460
reasoning_effort=args.reasoning_effort,
461+
gate=normalize_gate(args.gate),
452462
)
453463
return _run(config)
454464
except (
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# (C) 2026 GoodData Corporation
2+
"""Which of pass@K / pass^K decides an item, and how both reach Langfuse."""
3+
4+
from __future__ import annotations
5+
6+
from typing import Any
7+
8+
from gooddata_eval.core.config import DEFAULT_GATE, EvalGate, normalize_gate
9+
10+
__all__ = [
11+
"DEFAULT_GATE",
12+
"EvalGate",
13+
"gate_failure_note",
14+
"gate_label",
15+
"gate_passed",
16+
"log_gate_scores",
17+
"normalize_gate",
18+
"stamp_gate_metadata",
19+
]
20+
21+
22+
def gate_passed(gate: str | None, *, pass_at_k: bool, pass_power_k: bool) -> bool:
23+
"""Whether the item passes under ``gate``."""
24+
return pass_power_k if normalize_gate(gate) == "power" else pass_at_k
25+
26+
27+
def gate_label(gate: str | None, k: int) -> str:
28+
"""Short name for the gate, e.g. ``pass^3`` or ``pass@2``."""
29+
return f"pass^{k}" if normalize_gate(gate) == "power" else f"pass@{k}"
30+
31+
32+
def gate_failure_note(gate: str | None, run_passes: tuple[bool, ...] | list[bool]) -> str:
33+
"""Gate and how many runs met it, for the assertion message.
34+
35+
Needed because the message body shows the BEST run, which under pass^K can be a run that
36+
passed — so the reported diff on its own looks like a pass.
37+
"""
38+
k = len(run_passes)
39+
passed = sum(1 for p in run_passes if p)
40+
label = gate_label(gate, k)
41+
if normalize_gate(gate) == "power" and passed:
42+
return f"Gate {label} failed: {passed}/{k} runs passed — unstable, not a clean failure."
43+
return f"Gate {label} failed: {passed}/{k} runs passed."
44+
45+
46+
def log_gate_scores(langfuse: Any, trace_id: str | None, *, gate: str | None, pass_at_k: bool, pass_power_k: bool):
47+
"""Log both candidate verdicts and the one that decided.
48+
49+
The names carry no K on purpose: `pass_at_2` becomes `pass_at_3` the moment K changes,
50+
splitting every Langfuse view built on the old name.
51+
"""
52+
from gooddata_eval.core.agentic._langfuse import score_safe # noqa: PLC0415
53+
54+
score_safe(langfuse, trace_id, name="pass_at_k", value=pass_at_k, data_type="BOOLEAN")
55+
score_safe(langfuse, trace_id, name="pass_power_k", value=pass_power_k, data_type="BOOLEAN")
56+
score_safe(
57+
langfuse,
58+
trace_id,
59+
name="gate_passed",
60+
value=gate_passed(gate, pass_at_k=pass_at_k, pass_power_k=pass_power_k),
61+
data_type="BOOLEAN",
62+
)
63+
64+
65+
def stamp_gate_metadata(metadata: dict, *, k: int, gate: str | None) -> dict:
66+
"""Record K and the gate on the dataset-run metadata (mutates and returns it)."""
67+
metadata["eval_k"] = k
68+
metadata["eval_gate"] = normalize_gate(gate)
69+
return metadata

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/alert_skill.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
from gooddata_sdk import GoodDataSdk
1313

1414
from gooddata_eval.core.agentic._catalog import CatalogMetricAlert
15+
from gooddata_eval.core.agentic._gate import (
16+
DEFAULT_GATE,
17+
EvalGate,
18+
gate_failure_note,
19+
gate_passed,
20+
log_gate_scores,
21+
stamp_gate_metadata,
22+
)
1523
from gooddata_eval.core.chat.sse_client import ChatClient
1624
from gooddata_eval.core.config import ReasoningEffort
1725
from gooddata_eval.core.models import ToolCallEvent
@@ -313,6 +321,10 @@ class AgenticAlertSummary:
313321
pass_power_k: bool
314322
best: AlertRunResult
315323

324+
@property
325+
def run_passes(self) -> tuple[bool, ...]:
326+
return tuple(r.eval.strict_pass for r in self.run_results)
327+
316328

317329
def _case_insensitive_get(d: dict, *keys: str) -> Any:
318330
"""Look up a value by key, preferring an exact match then a case-insensitive one."""
@@ -548,6 +560,7 @@ def evaluate_agentic_alert_skill(
548560
question: str,
549561
expected_output: dict,
550562
k: int = _DEFAULT_K,
563+
gate: EvalGate = DEFAULT_GATE,
551564
max_iterations: int = _DEFAULT_MAX_ITERATIONS,
552565
initial_conversation_id: str | None = None,
553566
langfuse: object | None = None,
@@ -598,6 +611,7 @@ def evaluate_agentic_alert_skill(
598611
run_metadata_extra,
599612
reasoning_effort,
600613
)
614+
stamp_gate_metadata(run_metadata, k=len(summary.run_results), gate=gate)
601615
traces_by_conv = find_traces_per_conversation(
602616
langfuse,
603617
[r.conversation_id for r in summary.run_results],
@@ -620,6 +634,13 @@ def evaluate_agentic_alert_skill(
620634
with observe(langfuse, pt.id if pt else None, dataset_item_id, run_name, run_metadata) as tid:
621635
for score_name, value in strict_checks.items():
622636
score_safe(langfuse, tid, name=score_name, value=float(value), data_type="BOOLEAN")
637+
log_gate_scores(
638+
langfuse,
639+
tid,
640+
gate=gate,
641+
pass_at_k=summary.pass_at_k,
642+
pass_power_k=summary.pass_power_k,
643+
)
623644
log_quality_and_value_scores(
624645
langfuse,
625646
tid,
@@ -628,11 +649,12 @@ def evaluate_agentic_alert_skill(
628649
cost_usd=pt.total_cost if pt else None,
629650
)
630651

631-
if not summary.pass_at_k:
652+
if not gate_passed(gate, pass_at_k=summary.pass_at_k, pass_power_k=summary.pass_power_k):
653+
gate_note = gate_failure_note(gate, summary.run_passes)
632654
best = summary.best
633655
ev = best.eval
634656
raise AlertSkillAssertionError(
635-
f"Alert skill assertion failed. strict_pass={ev.strict_pass}. "
657+
f"Alert skill assertion failed. {gate_note} strict_pass={ev.strict_pass}. "
636658
f"alert_created={ev.alert_created}, operator_correct={ev.operator_correct}, "
637659
f"threshold_correct={ev.threshold_correct}, trigger_correct={ev.trigger_correct}, "
638660
f"filters_correct={ev.filters_correct}, metric_correct={ev.metric_correct}, "

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/general_question.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55

66
from dataclasses import dataclass
77

8+
from gooddata_eval.core.agentic._gate import (
9+
DEFAULT_GATE,
10+
EvalGate,
11+
gate_failure_note,
12+
gate_passed,
13+
log_gate_scores,
14+
stamp_gate_metadata,
15+
)
816
from gooddata_eval.core.chat.sse_client import ChatClient
917
from gooddata_eval.core.config import ReasoningEffort
1018
from gooddata_eval.core.evaluators._llm_judge import LLMJudge
@@ -63,6 +71,10 @@ class AgenticGeneralQuestionSummary:
6371
pass_power_k: bool
6472
best: GeneralQuestionResult
6573

74+
@property
75+
def run_passes(self) -> tuple[bool, ...]:
76+
return tuple(r.passed for r in self.run_results)
77+
6678

6779
def run_agentic_general_question(
6880
host: str,
@@ -148,6 +160,7 @@ def evaluate_agentic_general_question(
148160
question: str,
149161
expected_output: str,
150162
k: int = _DEFAULT_K,
163+
gate: EvalGate = DEFAULT_GATE,
151164
initial_conversation_id: str | None = None,
152165
langfuse: object | None = None,
153166
dataset_item_id: str = "",
@@ -196,6 +209,7 @@ def evaluate_agentic_general_question(
196209
run_metadata_extra,
197210
reasoning_effort,
198211
)
212+
stamp_gate_metadata(run_metadata, k=len(summary.run_results), gate=gate)
199213
traces_by_conv = find_traces_per_conversation(
200214
langfuse,
201215
[r.conversation_id for r in summary.run_results],
@@ -208,6 +222,13 @@ def evaluate_agentic_general_question(
208222
with observe(langfuse, pt.id if pt else None, dataset_item_id, run_name, run_metadata) as tid:
209223
score_safe(langfuse, tid, name="general_question_pass", value=float(run.passed), data_type="BOOLEAN")
210224
score_safe(langfuse, tid, name="llm_judge_score", value=run.llm_judge_score, data_type="NUMERIC")
225+
log_gate_scores(
226+
langfuse,
227+
tid,
228+
gate=gate,
229+
pass_at_k=summary.pass_at_k,
230+
pass_power_k=summary.pass_power_k,
231+
)
211232
log_quality_and_value_scores(
212233
langfuse,
213234
tid,
@@ -216,8 +237,9 @@ def evaluate_agentic_general_question(
216237
cost_usd=pt.total_cost if pt else None,
217238
)
218239

219-
if not summary.pass_at_k:
240+
if not gate_passed(gate, pass_at_k=summary.pass_at_k, pass_power_k=summary.pass_power_k):
241+
gate_note = gate_failure_note(gate, summary.run_passes)
220242
best = summary.best
221243
raise GeneralQuestionAssertionError(
222-
f"General question assertion failed. passed={best.passed}. Reasoning: {best.reasoning}"
244+
f"General question assertion failed. {gate_note} passed={best.passed}. Reasoning: {best.reasoning}"
223245
)

0 commit comments

Comments
 (0)