4646 success`` counts exactly one retry, not two.
4747* ``cached_calls``: entries with ``cached=True`` (runner semantics: reuse of a
4848 non-retriable failure result).
49+ * ``expected_outcomes``: machine-readable trajectory features a golden sample
50+ may require, from the fixed vocabulary ``guarded`` / ``cached`` / ``retry``.
51+ ``guarded`` is observed when any entry carries ``guarded=True`` (the stock-
52+ scope guard interception), ``cached`` when any entry carries ``cached=True``
53+ and ``retry`` when at least one retry is counted per the contract above. A
54+ required outcome that is not observed is reported as a violation, so a
55+ golden sample that declares guard / cache / retry expectations cannot be
56+ passed by a trajectory that skips the behaviour it describes.
4957* ``max_steps_touched``: the log does not carry ``max_steps`` itself, so this
5058 is the conservative heuristic ``max(step) >= golden.allowed_max_steps`` —
5159 a proxy for "the run reached the step budget", not proof of the loop
6270from pathlib import Path
6371from typing import Any , Dict , Iterable , List , Optional
6472
73+ #: Machine-readable trajectory features a golden sample may require of a log
74+ #: (``guarded`` = a guarded call, ``cached`` = a cached call, ``retry`` = at
75+ #: least one retry). See the "Metric semantics" section of the module docstring.
76+ EXPECTED_OUTCOME_TAGS = ("guarded" , "cached" , "retry" )
77+
6578
6679@dataclass
6780class GoldenSample :
6881 """Expected trajectory for one evaluation task.
6982
7083 ``expected_tools`` are the tool names the agent should call; tools outside
7184 this set are tolerated only when ``allow_optional_tools`` is true.
85+ ``expected_outcomes`` are required trajectory features from the vocabulary
86+ ``guarded`` / ``cached`` / ``retry`` (see the module docstring); every
87+ declared outcome must be observable in the log.
7288 """
7389
7490 id : str
@@ -78,6 +94,7 @@ class GoldenSample:
7894 skills : List [str ] = field (default_factory = list )
7995 allowed_max_steps : int = 10
8096 allow_optional_tools : bool = True
97+ expected_outcomes : List [str ] = field (default_factory = list )
8198
8299
83100@dataclass
@@ -150,6 +167,7 @@ def compute_trajectory_metrics(
150167 key_retries : Dict [tuple , int ] = {}
151168 failed_calls = 0
152169 cached_calls = 0
170+ guarded_calls = 0
153171 redundant_calls = 0
154172 distinct_steps = 0
155173 max_step = 0
@@ -171,6 +189,8 @@ def compute_trajectory_metrics(
171189 failed_calls += 1
172190 if entry .get ("cached" ):
173191 cached_calls += 1
192+ if entry .get ("guarded" ):
193+ guarded_calls += 1
174194
175195 key = (tool , _args_key (_entry_arguments (entry )))
176196 if key_counts .get (key , 0 ):
@@ -196,18 +216,26 @@ def compute_trajectory_metrics(
196216 distinct_steps = max (distinct_steps , total )
197217 max_step = max (max_step , total )
198218
219+ retries = sum (key_retries .values ())
220+ violations : List [str ] = []
221+
199222 if isinstance (golden .expected_tools , list ):
200223 expected = [t for t in golden .expected_tools if isinstance (t , str ) and t ]
201224 else :
202225 # Defend against hand-edited samples passing a bare string:
203226 # validation rejects it at load time, but scoring must not misparse
204227 # it into per-character tool names either.
205228 expected = []
229+ # A hand-edited sample may repeat a tool name; normalize to first
230+ # occurrences before scoring so the hit rate cannot be inflated
231+ # (["quote", "quote"] with one quote call must read 1/2, not 2/3).
232+ if len (set (expected )) != len (expected ):
233+ violations .append ("expected_tools contains duplicate names" )
234+ expected = list (dict .fromkeys (expected ))
206235 missing_expected = [t for t in expected if t not in used_tools ]
207236 expected_hit_rate = (len (expected ) - len (missing_expected )) / len (expected ) if expected else 0.0
208237 optional_tools_used = [t for t in used_tools if t not in expected ]
209238
210- violations : List [str ] = []
211239 if not expected :
212240 violations .append ("golden sample has no expected_tools" )
213241
@@ -221,6 +249,31 @@ def compute_trajectory_metrics(
221249 if optional_tools_used and not optional_allowed :
222250 violations .append (f"optional tools used but not allowed: { ', ' .join (optional_tools_used )} " )
223251
252+ # Machine-readable outcome expectations: each declared tag must be
253+ # observable in the log, otherwise the sample's guard / cache / retry
254+ # contract is violated (see the module docstring).
255+ if not isinstance (golden .expected_outcomes , list ):
256+ violations .append ("expected_outcomes must be a list of outcome tags" )
257+ outcomes : List [str ] = []
258+ else :
259+ outcomes = [t for t in golden .expected_outcomes if isinstance (t , str ) and t ]
260+ if len (set (outcomes )) != len (outcomes ):
261+ violations .append ("expected_outcomes contains duplicate tags" )
262+ outcomes = list (dict .fromkeys (outcomes ))
263+ unknown = [t for t in outcomes if t not in EXPECTED_OUTCOME_TAGS ]
264+ if unknown :
265+ violations .append (f"unknown expected outcome tags: { ', ' .join (unknown )} " )
266+ observed = []
267+ if guarded_calls :
268+ observed .append ("guarded" )
269+ if cached_calls :
270+ observed .append ("cached" )
271+ if retries :
272+ observed .append ("retry" )
273+ missing_outcomes = [t for t in outcomes if t in EXPECTED_OUTCOME_TAGS and t not in observed ]
274+ if missing_outcomes :
275+ violations .append (f"expected outcomes not observed: { ', ' .join (missing_outcomes )} " )
276+
224277 limit = golden .allowed_max_steps
225278 if isinstance (limit , bool ) or not isinstance (limit , int ):
226279 violations .append ("allowed_max_steps is not an integer" )
@@ -237,7 +290,7 @@ def compute_trajectory_metrics(
237290 redundant_calls = redundant_calls ,
238291 cached_calls = cached_calls ,
239292 failed_calls = failed_calls ,
240- retries = sum ( key_retries . values ()) ,
293+ retries = retries ,
241294 distinct_steps = distinct_steps ,
242295 max_steps_touched = max_steps_touched ,
243296 violations = violations ,
@@ -323,9 +376,11 @@ def validate_golden_sample(
323376
324377 Field *types* are part of the structural contract — hand-edited golden
325378 JSON must fail with a clear message instead of crashing or silently
326- passing: text fields must be strings, ``expected_tools``/``skills`` must
327- be lists, ``allowed_max_steps`` an integer and ``allow_optional_tools`` a
328- boolean.
379+ passing: text fields must be strings, ``expected_tools``/``skills`` and
380+ ``expected_outcomes`` must be lists, ``allowed_max_steps`` an integer and
381+ ``allow_optional_tools`` a boolean. ``expected_tools`` and
382+ ``expected_outcomes`` must be duplicate-free, and outcome tags must come
383+ from the fixed vocabulary ``guarded`` / ``cached`` / ``retry``.
329384 """
330385 issues : List [str ] = []
331386 known = set (known_tool_names ) if known_tool_names is not None else None
@@ -341,6 +396,8 @@ def validate_golden_sample(
341396 issues .append ("expected_tools must be a non-empty list" )
342397 elif any (not isinstance (t , str ) or not t .strip () for t in sample .expected_tools ):
343398 issues .append ("expected_tools must contain only non-empty strings" )
399+ elif len (set (sample .expected_tools )) != len (sample .expected_tools ):
400+ issues .append ("expected_tools must not contain duplicate names" )
344401 elif known is not None :
345402 # Only reachable when expected_tools is a non-empty list of non-empty
346403 # strings, so malformed values can never crash the membership check.
@@ -357,4 +414,14 @@ def validate_golden_sample(
357414 issues .append ("allowed_max_steps must be >= 1" )
358415 if not isinstance (sample .allow_optional_tools , bool ):
359416 issues .append ("allow_optional_tools must be a boolean" )
417+ if not isinstance (sample .expected_outcomes , list ):
418+ issues .append ("expected_outcomes must be a list of outcome tags" )
419+ elif any (not isinstance (t , str ) or not t .strip () for t in sample .expected_outcomes ):
420+ issues .append ("expected_outcomes must contain only non-empty strings" )
421+ elif len (set (sample .expected_outcomes )) != len (sample .expected_outcomes ):
422+ issues .append ("expected_outcomes must not contain duplicate tags" )
423+ else :
424+ unknown = [t for t in sample .expected_outcomes if t not in EXPECTED_OUTCOME_TAGS ]
425+ if unknown :
426+ issues .append (f"unknown expected_outcomes: { ', ' .join (unknown )} " )
360427 return issues
0 commit comments