@@ -72,22 +72,31 @@ def _engaged_skill_names(cmd: CommandTelemetry) -> set[str]:
7272 return names
7373
7474
75- def _first_engaged_skill_names (turn_records : list [TurnRecord ]) -> set [str ]:
76- """Skills engaged by the FIRST command that engages any skill (else empty).
77-
78- Activation measures which skill the agent selects *first*, so scoring keys off
79- the first engaging command rather than "any command anywhere in the run". This
80- keeps the final check and the live verdict consistent and prevents a later,
81- incidental engagement (or a second skill invoked alongside the first) from
82- being counted as a competing activation and mis-scored as a false positive.
83- Commands are scanned in ``sequence_number`` order (turn records preserve it).
75+ def _all_engaged_skill_names (turn_records : list [TurnRecord ]) -> set [str ]:
76+ """Union of every skill engaged anywhere in the trajectory (any-engagement).
77+
78+ Activation is scored on whether a skill was engaged *at all* during the run,
79+ not on which skill was engaged first. This has two consequences that the
80+ first-engagement policy could not express:
81+
82+ - **Recall (the positive criterion).** A row that engages the wrong skill
83+ before eventually engaging the expected one is still credited for the
84+ expected skill — reading a ``SKILL.md`` to compare candidates is
85+ exploration, not commitment, so an earlier wrong touch must not fail the
86+ row.
87+ - **Precision (the distractor/negative criteria).** An unrelated skill
88+ engaged *anywhere* is counted against its own criterion, so a positive row
89+ that also fires an off-target skill (and a negative row that fires any
90+ target skill) is penalized on that skill's confusion cell.
91+
92+ Order is irrelevant to a set union; the scan is left in ``sequence_number``
93+ order purely for determinism.
8494 """
95+ names : set [str ] = set ()
8596 for turn in turn_records :
8697 for cmd in turn .commands :
87- engaged = _engaged_skill_names (cmd )
88- if engaged :
89- return engaged
90- return set ()
98+ names .update (_engaged_skill_names (cmd ))
99+ return names
91100
92101
93102@register_criterion
@@ -101,8 +110,11 @@ class SkillTriggeredChecker(BaseCriterion[SkillTriggeredCriterion]):
101110 criterion_type = "skill_triggered"
102111
103112 # Observable mid-run: a Skill tool call (or a skill file read) is a positive
104- # event in the live stream, so both polarities are decidable the moment the
105- # agent first engages ANY skill (the first-engagement policy in live_verdict).
113+ # event in the live stream. The TYPE can decide either polarity — a positive
114+ # criterion live-passes when its expected skill is engaged, a
115+ # distractor/negative one live-fails when its (wrong) skill is engaged — but
116+ # any single INSTANCE decides only one of the two; see
117+ # ``live_decidable_polarities``.
106118 live_stop_polarities : ClassVar [frozenset [str ]] = frozenset ({"pass" , "fail" })
107119
108120 def _check_impl (
@@ -123,10 +135,13 @@ def _check_impl(
123135 error = "turn_records not provided to checker" ,
124136 )
125137
126- # First-engagement policy (mirrors ``live_verdict``): the run is scored on
127- # the FIRST skill the agent engages, so a second skill invoked alongside or
128- # after it is not counted as a competing activation.
129- triggered : bool = criterion .skill_name in _first_engaged_skill_names (turn_records )
138+ # Any-engagement policy (mirrors ``live_verdict``): the row is scored on
139+ # whether this skill was engaged AT ALL, regardless of order. A positive
140+ # criterion (skill_name == expected_skill) passes iff the expected skill
141+ # was engaged somewhere in the run — a wrong skill engaged first does not
142+ # fail it (recall). A distractor/negative criterion fails on ANY
143+ # engagement of its skill (precision).
144+ triggered : bool = criterion .skill_name in _all_engaged_skill_names (turn_records )
130145 expected_yes : bool = criterion .expected_skill == criterion .skill_name
131146 score = 1.0 if triggered == expected_yes else 0.0
132147 observed = _YES if triggered else _NO
@@ -147,29 +162,53 @@ def live_verdict(
147162 criterion : SkillTriggeredCriterion ,
148163 turn_records : list [TurnRecord ],
149164 ) -> LiveVerdict :
150- """First-engagement policy: the FIRST observed skill engagement decides.
151-
152- Activation measures which skill the agent selects *first*, so every
153- stacked ``skill_triggered`` criterion is decided simultaneously by the
154- first command that engages any skill:
155-
156- - before any engagement -> ``"undecided"``;
157- - on the first command engaging some skill: ``observed = (skill_name in
158- engaged)``, ``expected = (expected_skill == skill_name)`` ->
159- ``"pass"`` iff they match, else ``"fail"``.
160-
161- This covers the "wrong skill loads" case (a positive wrong signal) and
162- negative rows (``expected_skill == ""`` -> any engagement of the target
163- fails that criterion). ``_check_impl`` applies the SAME first-engagement
164- policy on the full trajectory, so the live verdict and the authoritative
165- score agree by construction — whether or not the run stopped early.
165+ """Any-engagement latch: decide the instant THIS skill is engaged.
166+
167+ Mirrors ``_check_impl``'s any-engagement policy, latched monotonically
168+ over the growing partial trajectory:
169+
170+ - this skill not engaged yet -> ``"undecided"`` (the final outcome still
171+ depends on the rest of the run — the expected skill may load later, or
172+ a distractor may yet fire);
173+ - this skill engaged -> ``expected_skill == skill_name`` decides:
174+ ``"pass"`` for a positive criterion (the expected skill loaded),
175+ ``"fail"`` for a distractor/negative one (a wrong skill loaded).
176+
177+ Because engagement is monotonic (a skill, once engaged, stays engaged), a
178+ latched verdict never flips, so it agrees with ``_check_impl`` on the
179+ frozen trajectory by construction — whether or not the run stopped early.
180+ A positive criterion can therefore only ever live-``pass`` and a
181+ distractor/negative one only ever live-``fail``; their *absence* is never
182+ decidable mid-run (see ``live_decidable_polarities``). This is the change
183+ from first-engagement: a wrong skill engaged first no longer live-fails a
184+ positive row — the run keeps going so the expected skill can still load.
166185 """
167- engaged = _first_engaged_skill_names (turn_records )
168- if not engaged :
186+ if criterion .skill_name not in _all_engaged_skill_names (turn_records ):
169187 return "undecided"
188+ return "pass" if criterion .expected_skill == criterion .skill_name else "fail"
189+
190+ @classmethod
191+ def live_decidable_polarities (cls , criterion : SkillTriggeredCriterion ) -> frozenset [str ]:
192+ """Per-instance narrowing under the any-engagement latch.
193+
194+ Unlike the type-level capability (``live_stop_polarities`` = both), a
195+ single instance decides exactly one polarity:
196+
197+ - a **positive** criterion (``skill_name == expected_skill``) can only
198+ live-``pass`` (the expected skill engaging is a decidable hit; its
199+ absence is not knowable mid-run);
200+ - a **distractor/negative** criterion (``skill_name != expected_skill``,
201+ including the ``expected_skill == ""`` negatives) can only
202+ live-``fail`` (a wrong skill engaging is a decidable miss; its absence
203+ is not).
204+
205+ ``validate_early_stop`` gates the requested ``stop_when`` on this set, so
206+ arming a positive with ``fail`` / a distractor with ``pass`` — or either
207+ with ``decided`` (which needs both) — is rejected at resolution rather
208+ than silently degrading to a full run.
209+ """
170210 expected_yes = criterion .expected_skill == criterion .skill_name
171- observed_yes = criterion .skill_name in engaged
172- return "pass" if observed_yes == expected_yes else "fail"
211+ return frozenset ({"pass" }) if expected_yes else frozenset ({"fail" })
173212
174213 def aggregate (
175214 self ,
0 commit comments