Skip to content

Commit 515b4f3

Browse files
committed
Align remaining direct-score drift with the validator
Two more validator/direct-score contract drifts from review: - allowed_max_steps <= 0 silently disabled the budget assertion on the direct-score path (no violation, max_steps_touched permanently False). Report the validator's wording (allowed_max_steps must be >= 1) and keep the budget assertion disabled only alongside the explicit violation. - whitespace-only expected_tools / expected_outcomes elements passed the malformed-element filter because it used not t while the validator uses not t.strip(): a whitespace tool polluted the hit-rate denominator and a whitespace outcome fell into the unknown-tag violation type. Both filters now use the validator's exact predicate. - allow_optional_tools wording aligned to the validator (must be a boolean) - caught by the new parity test. Add the owner-requested parity regression test: for each malformed golden shape the validator rejects, the direct-score path must surface the same issue wording in its violations.
1 parent e743fc0 commit 515b4f3

2 files changed

Lines changed: 90 additions & 13 deletions

File tree

evals/agent_trajectory/metrics.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -520,12 +520,15 @@ def compute_trajectory_metrics(
520520
structure contract as :func:`validate_golden_sample` on the loader path:
521521
malformed parts are reported as violations and excluded from scoring
522522
instead of silently reshaping the result. Malformed ``expected_tools`` /
523-
``expected_outcomes`` elements are dropped with an explicit violation,
524-
and a pinned ``expected_guarded_stock`` is only honoured for the coherent
525-
pairing the validator requires (``guarded_retry`` declared, and a stock
526-
different from ``golden.stock_code`` after canonicalization) — an
527-
unpaired pin is reported and disabled rather than silently erasing
528-
wrong-stock reporting.
523+
``expected_outcomes`` elements — non-strings, empty strings and
524+
whitespace-only strings, the validator's exact predicate — are dropped
525+
with an explicit violation, a non-positive ``allowed_max_steps`` is
526+
reported with the validator's wording (and the budget assertion stays
527+
disabled), and a pinned ``expected_guarded_stock`` is only honoured for
528+
the coherent pairing the validator requires (``guarded_retry`` declared,
529+
and a stock different from ``golden.stock_code`` after
530+
canonicalization) — an unpaired pin is reported and disabled rather than
531+
silently erasing wrong-stock reporting.
529532
"""
530533
used_tools: List[str] = []
531534
key_counts: Dict[tuple, int] = {}
@@ -564,8 +567,10 @@ def compute_trajectory_metrics(
564567
# below (mirroring validate_golden_sample, which rejects them at load
565568
# time) and only the valid names take part in scoring.
566569
if isinstance(golden.expected_tools, list):
567-
expected_tools_malformed = [t for t in golden.expected_tools if not isinstance(t, str) or not t]
568-
expected = [t for t in golden.expected_tools if isinstance(t, str) and t]
570+
# Same element predicate as validate_golden_sample(): whitespace-only
571+
# strings count as malformed too, not just falsy ones.
572+
expected_tools_malformed = [t for t in golden.expected_tools if not isinstance(t, str) or not t.strip()]
573+
expected = [t for t in golden.expected_tools if isinstance(t, str) and t.strip()]
569574
else:
570575
# Defend against hand-edited samples passing a bare string:
571576
# validation rejects it at load time, but scoring must not misparse
@@ -718,7 +723,7 @@ def compute_trajectory_metrics(
718723
# permissive, and a non-integer step limit must not crash the comparison.
719724
optional_allowed = golden.allow_optional_tools
720725
if not isinstance(optional_allowed, bool):
721-
violations.append("allow_optional_tools is not a boolean")
726+
violations.append("allow_optional_tools must be a boolean")
722727
optional_allowed = False
723728
if optional_tools_used and not optional_allowed:
724729
violations.append(f"optional tools used but not allowed: {', '.join(optional_tools_used)}")
@@ -730,13 +735,16 @@ def compute_trajectory_metrics(
730735
violations.append("expected_outcomes must be a list of outcome tags")
731736
outcomes: List[str] = []
732737
else:
733-
malformed_outcomes = [t for t in golden.expected_outcomes if not isinstance(t, str) or not t]
738+
# Same element predicate as validate_golden_sample(): whitespace-only
739+
# strings are malformed too, so they never fall through to the
740+
# unknown-tag check or the required-outcome set.
741+
malformed_outcomes = [t for t in golden.expected_outcomes if not isinstance(t, str) or not t.strip()]
734742
# Malformed outcome elements are reported (same wording as
735743
# validate_golden_sample) instead of silently dropping the
736744
# requirement they tried to declare.
737745
if malformed_outcomes:
738746
violations.append("expected_outcomes must contain only non-empty strings")
739-
outcomes = [t for t in golden.expected_outcomes if isinstance(t, str) and t]
747+
outcomes = [t for t in golden.expected_outcomes if isinstance(t, str) and t.strip()]
740748
if len(set(outcomes)) != len(outcomes):
741749
violations.append("expected_outcomes contains duplicate tags")
742750
outcomes = list(dict.fromkeys(outcomes))
@@ -789,6 +797,11 @@ def compute_trajectory_metrics(
789797
if isinstance(limit, bool) or not isinstance(limit, int):
790798
violations.append("allowed_max_steps is not an integer")
791799
limit = 0
800+
elif limit < 1:
801+
# Validator wording for the same field: a non-positive limit must
802+
# not silently disable the budget assertion on the direct-score path.
803+
violations.append("allowed_max_steps must be >= 1")
804+
limit = 0
792805
max_steps_touched = bool(max_step and limit > 0 and max_step >= limit)
793806
if max_steps_touched:
794807
violations.append(f"trajectory reached allowed_max_steps ({golden.allowed_max_steps})")

tests/test_agent_trajectory_metrics.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def test_non_bool_allow_optional_tools_scores_strictly(self):
190190
log,
191191
_golden(expected_tools=["get_realtime_quote"], allow_optional_tools="false"),
192192
)
193-
assert "allow_optional_tools is not a boolean" in m.violations
193+
assert "allow_optional_tools must be a boolean" in m.violations
194194
assert "optional tools used but not allowed: search_stock_news" in m.violations
195195

196196
def test_duplicate_expected_tools_scored_as_unique_names(self):
@@ -217,13 +217,24 @@ class TestDirectConstructionContract:
217217
def test_malformed_expected_tools_elements_are_reported(self):
218218
# Review counter-example: ['get_realtime_quote', ''] must not
219219
# silently collapse into a one-tool golden — the malformed element
220-
# is reported, only valid names take part in scoring.
220+
# is reported, only valid names take part in scoring. Whitespace-
221+
# only elements follow the validator's predicate (t.strip()).
221222
log = [_entry(tool="get_realtime_quote", arguments={"stock_code": "600519"})]
222223
for malformed in (["get_realtime_quote", ""], ["get_realtime_quote", 1]):
223224
m = compute_trajectory_metrics(log, _golden(expected_tools=malformed))
224225
assert m.expected_hit_rate == 1.0
225226
assert "expected_tools must contain only non-empty strings" in m.violations
226227

228+
def test_whitespace_only_expected_tool_does_not_pollute_scoring(self):
229+
# Review counter-example: ' ' must not enter the hit-rate
230+
# denominator nor show up as a missing tool — it is malformed.
231+
log = [_entry(tool="get_realtime_quote", arguments={"stock_code": "600519"})]
232+
m = compute_trajectory_metrics(log, _golden(expected_tools=["get_realtime_quote", " "]))
233+
assert m.expected_total == 1
234+
assert m.expected_hit_rate == 1.0
235+
assert m.missing_expected == []
236+
assert "expected_tools must contain only non-empty strings" in m.violations
237+
227238
def test_malformed_expected_outcomes_elements_are_reported(self):
228239
# Review counter-example: expected_outcomes=[1, ''] must not
229240
# silently drop the requirement — the malformed elements are
@@ -234,6 +245,59 @@ def test_malformed_expected_outcomes_elements_are_reported(self):
234245
assert "expected_outcomes must contain only non-empty strings" in m.violations
235246
assert "expected outcomes not observed" not in " ".join(m.violations)
236247

248+
def test_whitespace_only_outcome_gets_structural_violation(self):
249+
# Review counter-example: ' ' must be reported as a malformed
250+
# element, not misrouted into the unknown-tag violation type.
251+
log = [_entry(tool="get_realtime_quote", arguments={"stock_code": "600519"})]
252+
m = compute_trajectory_metrics(log, _golden(expected_outcomes=[" "]))
253+
assert "expected_outcomes must contain only non-empty strings" in m.violations
254+
assert not any("unknown expected outcome tags" in v for v in m.violations)
255+
256+
def test_non_positive_allowed_max_steps_reported_in_direct_score(self):
257+
# Review counter-example: allowed_max_steps=0 / -3 must report the
258+
# validator's wording instead of silently disabling the budget
259+
# assertion, no matter how many steps the trajectory takes.
260+
log = [_entry(tool="get_realtime_quote", arguments={"stock_code": "600519"}, step=s) for s in range(1, 100)]
261+
for limit in (0, -3):
262+
m = compute_trajectory_metrics(log, _golden(allowed_max_steps=limit))
263+
assert "allowed_max_steps must be >= 1" in m.violations
264+
assert m.max_steps_touched is False
265+
266+
def test_direct_score_mirrors_validator_structure_contract(self):
267+
# The owner-requested parity: for each malformed golden shape the
268+
# validator rejects, the direct-score path must surface the same
269+
# issue wording in its violations.
270+
log = [_entry(tool="get_realtime_quote", arguments={"stock_code": "600519"})]
271+
cases = [
272+
(dict(expected_tools=["get_realtime_quote", " "]), "expected_tools must contain only non-empty strings"),
273+
(dict(expected_tools=["get_realtime_quote", ""]), "expected_tools must contain only non-empty strings"),
274+
(dict(expected_outcomes=[" "]), "expected_outcomes must contain only non-empty strings"),
275+
(dict(expected_outcomes=[1, ""]), "expected_outcomes must contain only non-empty strings"),
276+
(dict(expected_outcomes="guarded"), "expected_outcomes must be a list of outcome tags"),
277+
(dict(allowed_max_steps=0), "allowed_max_steps must be >= 1"),
278+
(dict(allowed_max_steps=-3), "allowed_max_steps must be >= 1"),
279+
(dict(allow_optional_tools="false"), "allow_optional_tools must be a boolean"),
280+
(
281+
dict(expected_guarded_stock="600036"),
282+
"expected_guarded_stock requires guarded_retry in expected_outcomes",
283+
),
284+
(
285+
dict(
286+
stock_code="600036",
287+
expected_tools=["get_stock_info"],
288+
expected_outcomes=["guarded_retry"],
289+
expected_guarded_stock="SH600036",
290+
),
291+
"expected_guarded_stock must name a different stock than stock_code after canonicalization (it names the out-of-scope call)",
292+
),
293+
]
294+
for overrides, issue in cases:
295+
golden = _golden(**overrides)
296+
validator_issues = validate_golden_sample(golden)
297+
assert any(issue in i for i in validator_issues), (issue, overrides, validator_issues)
298+
m = compute_trajectory_metrics(log, golden)
299+
assert any(issue in v for v in m.violations), (issue, overrides, m.violations)
300+
237301
def test_unpaired_guarded_stock_reported_and_exemption_disabled(self):
238302
# Review counter-example: expected_guarded_stock without guarded_retry
239303
# is a malformed sample; it must be reported and must not erase the

0 commit comments

Comments
 (0)