@@ -127,6 +127,78 @@ def _check_filters(expected: CatalogMetricAlert, actual_args: dict) -> bool:
127127 return _deep_subset (exp_filters , act_filters )
128128
129129
130+ def _attribute_label_ids (items : list , * , side : str ) -> list [str ]:
131+ """Canonicalise group-by entries to bare label ids, whatever spelling they arrive in.
132+
133+ The two sides of the comparison speak different vocabularies for the same grouping.
134+ Fixtures author the AAC tool-input form, ``{"using": "label/x"}``; ``create_metric_alert``
135+ receives the resolved AFM form, ``{"localIdentifier": "a0", "label": {"identifier":
136+ {"id": "x", "type": "label"}}}``, forwarded verbatim from ``prepare_metric_alert_proposal``.
137+ Identity is therefore the only thing they can be compared on.
138+
139+ A shape not listed here, or a URI prefix other than ``label/``, raises: ``label/x`` and
140+ ``attribute/x`` are different objects, and an unknown spelling must fail loudly rather
141+ than quietly compare unequal.
142+ """
143+ if not isinstance (items , list ):
144+ raise ValueError (f"Unrecognised { side } group-by attributes, expected a list: { items !r} " )
145+ ids : list [str ] = []
146+ for item in items :
147+ raw : object = None
148+ if isinstance (item , str ):
149+ raw = item
150+ elif isinstance (item , dict ):
151+ label = item .get ("label" )
152+ identifier = item .get ("identifier" )
153+ if isinstance (item .get ("using" ), str ):
154+ raw = item ["using" ]
155+ elif isinstance (label , dict ) and isinstance (label .get ("identifier" ), dict ):
156+ raw = label ["identifier" ].get ("id" )
157+ elif isinstance (identifier , dict ):
158+ raw = identifier .get ("id" )
159+ if not isinstance (raw , str ) or not raw :
160+ raise ValueError (f"Unrecognised { side } group-by attribute entry: { item !r} " )
161+ prefix , slash , rest = raw .partition ("/" )
162+ if not slash :
163+ ids .append (raw )
164+ elif prefix == "label" and rest :
165+ ids .append (rest )
166+ else :
167+ raise ValueError (f"Unrecognised { side } group-by attribute reference: { raw !r} " )
168+ return ids
169+
170+
171+ def _check_attributes (expected : CatalogMetricAlert , actual_args : dict ) -> bool :
172+ """Compare group-by identity only.
173+
174+ Per-entry properties — ``showAllValues``, the converter-assigned ``localIdentifier`` —
175+ are deliberately not asserted, and the comparison is a multiset so entry order does not
176+ matter.
177+ """
178+ exp_attributes = expected .attributes
179+ if exp_attributes is None :
180+ return True
181+ act_attributes = actual_args .get ("attributes" )
182+ if act_attributes is None :
183+ # Arguments are raw `json.loads` output, where an unset nullable argument arrives as
184+ # null rather than absent. Both spellings of "no grouping" have to land on [], which
185+ # is why this is not `actual_args.get("attributes", [])`.
186+ act_attributes = []
187+ elif not isinstance (act_attributes , list ):
188+ # An argument that is not a list of groupings is the agent answering wrongly, so it
189+ # scores False. Raising instead would make the runner record an ERROR, and errored
190+ # items are excluded from the failure count — a malformed answer must not rank above
191+ # a merely wrong one. An unreadable *entry* still raises, in `_attribute_label_ids`:
192+ # entries are typed at the tool boundary, so the plausible cause there is the wire
193+ # format moving, which has to be unmissable.
194+ return False
195+ if not exp_attributes :
196+ return not act_attributes
197+ exp_ids = sorted (_attribute_label_ids (exp_attributes , side = "expected" ))
198+ act_ids = sorted (_attribute_label_ids (act_attributes , side = "actual" ))
199+ return exp_ids == act_ids
200+
201+
130202def _check_metric (expected : CatalogMetricAlert , actual_args : dict ) -> bool :
131203 if not expected .metric_id :
132204 return True
@@ -335,6 +407,7 @@ class AlertEvaluation:
335407 filters_correct : bool
336408 metric_correct : bool
337409 recipients_correct : bool
410+ attributes_correct : bool = True
338411
339412 @property
340413 def strict_pass (self ) -> bool :
@@ -347,6 +420,7 @@ def strict_pass(self) -> bool:
347420 self .filters_correct ,
348421 self .metric_correct ,
349422 self .recipients_correct ,
423+ self .attributes_correct ,
350424 ]
351425 )
352426
@@ -410,6 +484,35 @@ def _normalize_expected_filters(expected: dict) -> list | str | None:
410484 return None
411485
412486
487+ _NO_GROUPING_MARKERS = ("none" , "no grouping" )
488+
489+
490+ def _normalize_expected_attributes (expected : dict ) -> list | None :
491+ """
492+ * ``Attributes`` list -> that list (exact expectation)
493+ * "None" / "no grouping" -> ``[]`` (stated: no group-by; extras fail)
494+ * absent, or other prose -> ``None`` (unstated; grouping not asserted)
495+
496+ A date narrows an alert as a group-by as well as a filter, and a group-by makes it fire
497+ per period value instead of on the latest one — so ``[]`` has to be expressible separately
498+ from "absent", exactly as it is for ``filters``.
499+
500+ The simulated user is told nothing about groupings, so a non-empty expectation requires the
501+ item's own question to request that grouping; ``[]`` needs no such support, because the
502+ simulated user does not invent a grouping and the check verifies it did not.
503+ """
504+ attributes = _case_insensitive_get (expected , "attributes" )
505+ if isinstance (attributes , list ):
506+ # Validated here so a malformed fixture fails before the run spends an API call.
507+ _attribute_label_ids (attributes , side = "expected" )
508+ return attributes
509+ if attributes is None :
510+ return None
511+ if isinstance (attributes , str ):
512+ return [] if any (kw in attributes .lower () for kw in _NO_GROUPING_MARKERS ) else None
513+ raise ValueError (f"Attributes expectation must be a list or a display string, got { type (attributes ).__name__ } " )
514+
515+
413516def _normalize_expected_output (expected : dict ) -> CatalogMetricAlert :
414517 """Parse expected_output dict into CatalogMetricAlert, accepting display-format or internal-format keys."""
415518 operator = _case_insensitive_get (expected , "operator" ) or "GREATER_THAN"
@@ -434,6 +537,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
434537 recipients = list (raw_recip )
435538
436539 filters = _normalize_expected_filters (expected )
540+ attributes = _normalize_expected_attributes (expected )
437541
438542 return CatalogMetricAlert (
439543 operator = operator ,
@@ -444,6 +548,7 @@ def _normalize_expected_output(expected: dict) -> CatalogMetricAlert:
444548 metric_id = metric_id ,
445549 recipients = recipients ,
446550 filters = filters ,
551+ attributes = attributes ,
447552 )
448553
449554
@@ -564,6 +669,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
564669 filters_correct = tool_called and _check_filters (expected , actual_args ),
565670 metric_correct = tool_called and _check_metric (expected , actual_args ),
566671 recipients_correct = tool_called and _check_recipients (expected , actual_args , sdk = sdk ),
672+ attributes_correct = tool_called and _check_attributes (expected , actual_args ),
567673 )
568674 return AlertRunResult (
569675 conversation_id = conv_id ,
@@ -609,6 +715,7 @@ def _run_once(conv_id: str) -> AlertRunResult:
609715 r .eval .filters_correct ,
610716 r .eval .metric_correct ,
611717 r .eval .recipients_correct ,
718+ r .eval .attributes_correct ,
612719 ]
613720 ),
614721 )
@@ -683,6 +790,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
683790 "filters_correct" : ev .filters_correct ,
684791 "metric_correct" : ev .metric_correct ,
685792 "recipients_correct" : ev .recipients_correct ,
793+ "attributes_correct" : ev .attributes_correct ,
686794 }
687795 with ctx .observe (pt , run_idx ) as tid :
688796 for score_name , value in strict_checks .items ():
@@ -729,6 +837,7 @@ def _write_scores(ctx: RunTraceContext) -> None:
729837 "filters_correct" : ev .filters_correct ,
730838 "metric_correct" : ev .metric_correct ,
731839 "recipients_correct" : ev .recipients_correct ,
840+ "attributes_correct" : ev .attributes_correct ,
732841 "actual_alert_arguments" : best .actual_alert_arguments ,
733842 "latency_breakdown" : build_latency_breakdown (best .tool_call_events , best .reasoning_step_events ),
734843 }
@@ -739,7 +848,8 @@ def _write_scores(ctx: RunTraceContext) -> None:
739848 f"alert_created={ ev .alert_created } , operator_correct={ ev .operator_correct } , "
740849 f"threshold_correct={ ev .threshold_correct } , trigger_correct={ ev .trigger_correct } , "
741850 f"filters_correct={ ev .filters_correct } , metric_correct={ ev .metric_correct } , "
742- f"recipients_correct={ ev .recipients_correct } . "
851+ f"recipients_correct={ ev .recipients_correct } , "
852+ f"attributes_correct={ ev .attributes_correct } . "
743853 f"Actual args: { best .actual_alert_arguments } "
744854 )
745855 exc .reasoning_steps = best .reasoning_steps
0 commit comments