Skip to content

Commit d7e9a7d

Browse files
Tomkessclaude
andcommitted
fix(gooddata-eval): guard out-of-range date offsets and share one scoring anchor
Two defects in relative-date scoring, both found in review of this PR. An offset far enough out to leave the representable date range raised out of _absolute_span instead of resolving. Every granularity can be pushed past it: a YEAR offset of -today.year lands on year 0, large DAY/WEEK_US offsets exceed timedelta's magnitude limit, and large MONTH/QUARTER offsets leave 1..9999. The exception escaped through _normalize_date_filter and check_filters, failing the whole item over one malformed filter. It now resolves to None, putting the filter back on the literal-comparison path an unresolvable span already takes. Separately, check_filters shared one anchor across its two sides, but the evaluator resolved the filters it reports through separate normalized_filters calls, each taking its own date.today(). A run straddling midnight could report periods the verdict was never computed from -- a detail contradicting its own score. The anchor is now captured once in _evaluate_visualization and passed to check_filters and both normalized_filters calls; _evaluate_against_candidates captures one too, so candidates are never ranked on spans from different days. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 118c27b commit d7e9a7d

4 files changed

Lines changed: 97 additions & 19 deletions

File tree

‎packages/gooddata-eval/src/gooddata_eval/core/evaluators/visualization.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""Agentic visualization evaluator — ported from gdc-nas tavern-e2e app/vis_agentic.py."""
33

44
from dataclasses import dataclass
5+
from datetime import date
56

67
from gooddata_eval.core.evaluators.base import ItemEvaluation
78
from gooddata_eval.core.models import (
@@ -85,7 +86,13 @@ def _evaluate_visualization(
8586
expected: CreatedVisualization,
8687
actual: CreatedVisualization | None,
8788
skill_activated: bool = False,
89+
today: date | None = None,
8890
) -> EvaluationResult:
91+
# One anchor for the scoring and for the filters reported beside it. check_filters
92+
# already shares an anchor across its two sides, but resolving the reported filters
93+
# separately would let a run that straddles midnight report periods the score was
94+
# never computed from -- a detail that contradicts its own verdict.
95+
today = today or date.today()
8996
exp_metric_uris = get_metric_uri_set(expected)
9097
exp_dim_uris = get_dimension_uri_set(expected)
9198
if actual is None:
@@ -105,13 +112,13 @@ def _evaluate_visualization(
105112
actual_metric_uris=set(),
106113
expected_dim_uris=exp_dim_uris,
107114
actual_dim_uris=set(),
108-
expected_filters=normalized_filters(expected),
115+
expected_filters=normalized_filters(expected, today),
109116
actual_filters={category: values.copy() for category, values in _NO_FILTERS.items()},
110117
)
111118
cross_ref_valid, cross_ref_errors = validate_cross_references(actual)
112119
act_metric_uris = get_metric_uri_set(actual)
113120
act_dim_uris = get_dimension_uri_set(actual)
114-
filter_scores = check_filters(expected, actual)
121+
filter_scores = check_filters(expected, actual, today)
115122
return EvaluationResult(
116123
visualization_created=True,
117124
cross_ref_valid=cross_ref_valid,
@@ -128,17 +135,21 @@ def _evaluate_visualization(
128135
actual_metric_uris=act_metric_uris,
129136
expected_dim_uris=exp_dim_uris,
130137
actual_dim_uris=act_dim_uris,
131-
expected_filters=normalized_filters(expected),
132-
actual_filters=normalized_filters(actual),
138+
expected_filters=normalized_filters(expected, today),
139+
actual_filters=normalized_filters(actual, today),
133140
)
134141

135142

136143
def _evaluate_against_candidates(
137144
expected_outputs: list[CreatedVisualization],
138145
actual: CreatedVisualization | None,
139146
skill_activated: bool = False,
147+
today: date | None = None,
140148
) -> tuple[EvaluationResult, CreatedVisualization]:
141-
pairs = [(_evaluate_visualization(exp, actual, skill_activated), exp) for exp in expected_outputs]
149+
# Anchored once here too: the candidates are ranked against each other, so letting
150+
# each resolve its own today could rank them on spans from different days.
151+
today = today or date.today()
152+
pairs = [(_evaluate_visualization(exp, actual, skill_activated, today), exp) for exp in expected_outputs]
142153
best_result, best_expected = max(pairs, key=lambda p: (p[0].strict_pass, p[0].strict_checks_passed_count))
143154
return best_result, best_expected
144155

‎packages/gooddata-eval/src/gooddata_eval/core/scoring.py‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,21 +122,30 @@ def _absolute_span(granularity: str, start_offset: int, end_offset: int, today:
122122
``WEEK``, which is not in the AAC granularity enum and states no start-of-week
123123
convention. ``WEEK_US`` *is* well defined (Sunday-start), so it resolves.
124124
Guessing the bare case would trade a false negative for a false positive.
125+
126+
An offset far enough out to leave the representable date range resolves to None
127+
rather than raising: every granularity here can be pushed past it (a YEAR offset of
128+
``-today.year`` alone lands on year 0), and letting that escape would abort scoring
129+
for the whole item over one malformed filter. None puts the filter back on the
130+
literal-comparison path, which is what an unresolvable span already does.
125131
"""
126132
gran = granularity.upper()
127-
if gran == "DAY":
128-
return today + timedelta(days=start_offset), today + timedelta(days=end_offset)
129-
if gran == "WEEK_US":
130-
sunday = today - timedelta(days=(today.weekday() + 1) % 7)
131-
return sunday + timedelta(weeks=start_offset), sunday + timedelta(weeks=end_offset, days=6)
132-
if gran == "MONTH":
133-
return _shift_month(today, start_offset)[0], _shift_month(today, end_offset)[1]
134-
if gran == "QUARTER":
135-
q_start_month = (today.month - 1) // 3 * 3 + 1
136-
anchor = date(today.year, q_start_month, 1)
137-
return _shift_month(anchor, start_offset * 3)[0], _shift_month(anchor, end_offset * 3 + 2)[1]
138-
if gran == "YEAR":
139-
return date(today.year + start_offset, 1, 1), date(today.year + end_offset, 12, 31)
133+
try:
134+
if gran == "DAY":
135+
return today + timedelta(days=start_offset), today + timedelta(days=end_offset)
136+
if gran == "WEEK_US":
137+
sunday = today - timedelta(days=(today.weekday() + 1) % 7)
138+
return sunday + timedelta(weeks=start_offset), sunday + timedelta(weeks=end_offset, days=6)
139+
if gran == "MONTH":
140+
return _shift_month(today, start_offset)[0], _shift_month(today, end_offset)[1]
141+
if gran == "QUARTER":
142+
q_start_month = (today.month - 1) // 3 * 3 + 1
143+
anchor = date(today.year, q_start_month, 1)
144+
return _shift_month(anchor, start_offset * 3)[0], _shift_month(anchor, end_offset * 3 + 2)[1]
145+
if gran == "YEAR":
146+
return date(today.year + start_offset, 1, 1), date(today.year + end_offset, 12, 31)
147+
except (ValueError, OverflowError):
148+
return None
140149
return None
141150

142151

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# (C) 2026 GoodData Corporation
22
from datetime import date
33

4+
import pytest
45
from gooddata_eval.core.models import CreatedVisualization
56
from gooddata_eval.core.scoring import (
67
check_filters,
@@ -270,3 +271,26 @@ def test_check_filters_date_still_distinguishes_the_dataset_it_hangs_off():
270271
expected = _date_viz(**{"from": -1, "to": -1, "granularity": "MONTH"})
271272
actual = _date_viz(using="dataset/dt_date", **{"from": -1, "to": -1, "granularity": "MONTH"})
272273
assert check_filters(expected, actual, _TODAY).date_ok is False
274+
275+
276+
@pytest.mark.parametrize(
277+
("granularity", "offset"),
278+
[
279+
("YEAR", -_TODAY.year), # lands on year 0
280+
("DAY", -(10**9)), # past timedelta's magnitude limit
281+
("WEEK_US", -(10**8)),
282+
("MONTH", -30000),
283+
("QUARTER", -10000),
284+
],
285+
)
286+
def test_check_filters_out_of_range_offsets_fall_back_instead_of_raising(granularity, offset):
287+
"""An offset outside the representable date range must not abort scoring.
288+
289+
Every granularity can be pushed past date's 1..9999 year range (or timedelta's
290+
magnitude limit). Letting the ValueError/OverflowError escape would fail the whole
291+
item on one malformed filter, so the span resolves to None and the filter goes back
292+
to literal comparison -- which still matches an identically malformed expectation.
293+
"""
294+
expected = _date_viz(**{"from": offset, "to": 0, "granularity": granularity})
295+
assert check_filters(expected, _date_viz(**{"from": offset, "to": 0, "granularity": granularity}), _TODAY).date_ok
296+
assert not check_filters(expected, _date_viz(**{"from": -1, "to": -1, "granularity": "MONTH"}), _TODAY).date_ok

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# (C) 2026 GoodData Corporation
22
import re
3+
from datetime import date
34

45
from gooddata_eval.core.evaluators import get_evaluator
5-
from gooddata_eval.core.models import ChatResult, DatasetItem
6+
from gooddata_eval.core.evaluators.visualization import _evaluate_visualization
7+
from gooddata_eval.core.models import ChatResult, CreatedVisualization, DatasetItem
68

79

810
def _item(expected_viz) -> DatasetItem:
@@ -175,3 +177,35 @@ def test_detail_filters_are_empty_when_no_visualization_was_created():
175177
result = ev.evaluate(_item(_dated("MONTH", -11, 0)), empty)
176178
assert result.detail["actual_filters"] == {"date": [], "ranking": [], "attribute": []}
177179
assert len(result.detail["expected_filters"]["date"]) == 1
180+
181+
182+
def _date_filtered_viz():
183+
return {
184+
"id": "x",
185+
"type": "table",
186+
"query": {
187+
"fields": {"m_rev": {"using": "metric/revenue"}},
188+
"filter_by": {
189+
"f": {"type": "date_filter", "using": "dataset/dt", "from": -1, "to": -1, "granularity": "MONTH"}
190+
},
191+
},
192+
"metrics": ["m_rev"],
193+
}
194+
195+
196+
def test_reported_filters_use_the_same_date_anchor_as_the_score():
197+
"""The filters reported in detail must be resolved against the anchor the score used.
198+
199+
check_filters shares one anchor across its two sides, but the reported filters were
200+
resolved by separate normalized_filters calls, each taking its own date.today(). A run
201+
straddling midnight could therefore report periods the verdict was never computed from.
202+
Pinning an explicit anchor here fails unless it reaches every one of those calls.
203+
"""
204+
viz = CreatedVisualization.model_validate(_date_filtered_viz())
205+
result = _evaluate_visualization(viz, viz, today=date(2026, 3, 15))
206+
207+
# -1 MONTH from 2026-03-15 is February 2026, not whatever month the suite runs in.
208+
assert result.filter_date_score is True
209+
assert '"from": "2026-02-01"' in result.expected_filters["date"][0]
210+
assert '"to": "2026-02-28"' in result.expected_filters["date"][0]
211+
assert result.expected_filters["date"] == result.actual_filters["date"]

0 commit comments

Comments
 (0)