Skip to content

Commit 56ea6b4

Browse files
Tomkessclaude
andcommitted
fix(gooddata-eval): let --gate decide an anomaly item, not pass@K alone
`run_agentic_anomaly_detection` already computed `pass_power_k`; the evaluator never read it, asking `if not summary.pass_at_k` for the verdict, so `--gate power` on a flaky item reported a pass on the strength of one good run out of K. Wired the same way as the other multi-run kinds: the evaluator takes `gate`, the dispatch passes it, `gate_passed(...)` decides, the gate's note goes into the assertion message -- which matters because the body describes the BEST run, and under pass^K that can be one that passed -- and pass@K/pass^K/gate_passed reach Langfuse alongside the run metadata stamp. The default is unchanged: without --gate this is pass@K exactly as before, pinned by a test beside the power case. The power test fails against the previous code. Found by CodeRabbit on #1798. Forecasting is fixed there; what-if, which is already on master, in #1831. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 5632da8)
1 parent d0ac01f commit 56ea6b4

3 files changed

Lines changed: 65 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ def _dispatch_agentic(
326326
question=item.question,
327327
expected_output=eo if isinstance(eo, dict) else {},
328328
k=k,
329+
gate=gate,
329330
agent_id=agent_id,
330331
**lf_kw,
331332
)

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232
from typing import Any
3333

3434
from gooddata_eval.core.agentic._failed_runs import build_failed_runs
35+
from gooddata_eval.core.agentic._gate import (
36+
DEFAULT_GATE,
37+
EvalGate,
38+
gate_failure_note,
39+
gate_passed,
40+
log_gate_scores,
41+
stamp_gate_metadata,
42+
)
3543
from gooddata_eval.core.agentic._trace_linker import (
3644
RunIdentity,
3745
RunTraceContext,
@@ -503,6 +511,7 @@ def evaluate_agentic_anomaly_detection(
503511
k: int = _DEFAULT_K,
504512
max_iterations: int = _DEFAULT_MAX_ITERATIONS,
505513
initial_conversation_id: str | None = None,
514+
gate: EvalGate = DEFAULT_GATE,
506515
agent_id: str | None = None,
507516
langfuse: object | None = None,
508517
dataset_item_id: str = "",
@@ -533,6 +542,8 @@ def evaluate_agentic_anomaly_detection(
533542
window_end = utc_now()
534543

535544
def _write_scores(ctx: RunTraceContext) -> None:
545+
stamp_gate_metadata(ctx.run_metadata, k=len(summary.run_results), gate=gate)
546+
536547
for run_idx, run in enumerate(summary.run_results):
537548
pt = ctx.trace(run.conversation_id)
538549
ev = run.evaluation
@@ -558,6 +569,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
558569
with ctx.observe(pt, run_idx) as tid:
559570
for score_name, value in strict_checks.items():
560571
ctx.score(tid, name=score_name, value=float(value), data_type="BOOLEAN")
572+
log_gate_scores(ctx, tid, gate=gate, pass_at_k=summary.pass_at_k, pass_power_k=summary.pass_power_k)
561573
ctx.quality(
562574
tid,
563575
strict_checks=strict_checks,
@@ -610,9 +622,10 @@ def _write_scores(ctx: RunTraceContext) -> None:
610622
)
611623
runs_passed = sum(1 for r in summary.run_results if r.evaluation.strict_pass)
612624

613-
if not summary.pass_at_k:
625+
if not gate_passed(gate, pass_at_k=summary.pass_at_k, pass_power_k=summary.pass_power_k):
626+
gate_note = gate_failure_note(gate, runs_passed, len(summary.run_results))
614627
message = (
615-
f"Anomaly detection assertion failed. strict_pass={ev.strict_pass} "
628+
f"Anomaly detection assertion failed. {gate_note} strict_pass={ev.strict_pass} "
616629
f"(triggered={ev.triggered}, executed={ev.executed}, success={ev.success}, "
617630
f"turn_completed={ev.turn_completed}, metric_correct={ev.metric_correct}, "
618631
f"granularity_correct={ev.granularity_correct}). "

‎packages/gooddata-eval/tests/test_agentic_anomaly_detection.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,8 @@ def __init__(self):
410410
self.scores: dict[str, float] = {}
411411
# Not named `quality`: the method below would overwrite itself on first call.
412412
self.quality_call: dict = {}
413+
# stamp_gate_metadata writes the gate and K here, the way every other kind does.
414+
self.run_metadata: dict = {}
413415

414416
def trace(self, _conversation_id):
415417
return None
@@ -479,3 +481,50 @@ def test_cost_is_reported_even_when_the_tool_was_never_reached():
479481
ev.triggered hid that and understated what the item cost."""
480482
ctx = _scored(_EXPECTED, calls=[])
481483
assert "cost_usd" in ctx.quality_call
484+
485+
486+
# ── the gate decides the verdict, not pass@K alone ──────────────────────────
487+
#
488+
# run_agentic_anomaly_detection computed pass^K and the evaluator never read it, so
489+
# `--gate power` on a flaky item passed on the strength of one good run out of K -- the
490+
# precise case the gate exists to catch.
491+
492+
493+
def _two_runs(gate=None):
494+
"""One passing run then one failing one, which pass@K clears and pass^K must not."""
495+
client = MagicMock()
496+
client.create_conversation.side_effect = ["conv-1", "conv-2"]
497+
client.send_message.side_effect = [_chat(_pair()), *[_chat([])] * 8]
498+
kwargs = {"gate": gate} if gate is not None else {}
499+
with (
500+
patch(f"{_MODULE}.ChatClient", return_value=client),
501+
patch(f"{_MODULE}.generate_simulated_anomaly_response", return_value="use spend, monthly"),
502+
):
503+
return evaluate_agentic_anomaly_detection(
504+
host="http://h",
505+
token="tok",
506+
workspace_id="ws1",
507+
question="Any anomalies in monthly spend?",
508+
expected_output=_EXPECTED,
509+
k=2,
510+
**kwargs,
511+
)
512+
513+
514+
def test_power_gate_fails_an_item_where_only_some_runs_passed():
515+
with pytest.raises(AnomalyDetectionAssertionError) as excinfo:
516+
_two_runs(gate="power")
517+
assert "pass^2" in str(excinfo.value)
518+
assert excinfo.value.runs_passed == 1
519+
assert excinfo.value.runs_effective == 2
520+
521+
522+
def test_any_gate_still_passes_the_same_item():
523+
"""The default is unchanged: one passing run out of K clears pass@K."""
524+
outcome = _two_runs(gate="any")
525+
assert outcome.runs_passed == 1
526+
assert outcome.runs_effective == 2
527+
528+
529+
def test_the_default_gate_is_pass_at_k():
530+
assert _two_runs().runs_passed == 1

0 commit comments

Comments
 (0)