@@ -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