Skip to content

Commit 0b093b1

Browse files
mohsen-uipathanonymous
authored andcommitted
fix(early-stop): latch skill activation on any engagement, not first
The skill_triggered criterion decided activation on the FIRST skill the agent engaged, so a row failed when the agent touched a wrong skill before engaging the expected one -- even though reading a SKILL.md is comparison, not commitment. Decide on ANY engagement instead: - _check_impl and live_verdict now share _all_engaged_skill_names (the union across the whole trajectory). A positive criterion (skill_name == expected_skill) passes iff the expected skill is engaged anywhere in the run (recall); a distractor/negative criterion fails on any engagement of its skill (precision), so an extra off-target engagement lands on its own confusion cell rather than opening a precision hole. Negatives (expected_skill == "") still fail on any engagement of the target skill. - Engagement is monotonic, so the live latch and the frozen-trajectory score agree by construction. - live_decidable_polarities now narrows per instance: a positive can only live-pass, a distractor/negative only live-fail. Arming a positive with fail (or either with decided) is rejected at resolution instead of silently degrading to a full run. Also confirms the early-stop watcher latches on a file-read engagement (Read/Grep of skills/<name>/...), not only a Claude Skill tool call, so early-stop fires for non-Claude agents. Adds coverage for the any-engagement scoring, stacked recall/precision on one trajectory, per-instance decidability, and the file-read latch.
1 parent d1cb685 commit 0b093b1

4 files changed

Lines changed: 254 additions & 103 deletions

File tree

src/coder_eval/criteria/skill_triggered.py

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

src/coder_eval/orchestration/early_stop.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,9 @@ def _evaluate(self, in_flight: CommandTelemetry | None = None) -> None:
296296
record = self._collector.build_turn_record()
297297
if in_flight is not None:
298298
# The in-flight call has no ToolEnd yet, so the collector (which
299-
# reduces commands from ToolEnd) has not captured it. Append it and
300-
# re-sort by sequence so the verdict sees it in first-engagement order.
299+
# reduces commands from ToolEnd) has not captured it. Append it so its
300+
# engagement is visible to the verdict; re-sort by sequence to keep the
301+
# partial trajectory in emission order.
301302
record.commands = sorted([*record.commands, in_flight], key=lambda c: c.sequence_number)
302303
records = [record]
303304
# An in-flight call has not been counted by a ToolEnd yet, so report it as

0 commit comments

Comments
 (0)