Skip to content

Commit 607cd22

Browse files
fix(criteria): stop ignore_flags re-opening the guard false-PASS
ignore_flags was folded into the value-bearing set to keep `--output json` from leaving `json` in the positionals. That made every ignored flag value-bearing, including switches: with ignore_flags: ["verbose"], `delete --verbose proj-1` bound verbose=proj-1, emptied the positionals, and a max_count: 0 guard on positional: [proj-1] returned 1.0 on the delete it forbade — the same failure as the --yes bug, through the last field where binding was still guessed. Value binding is now declarations only (flag predicates that need a value, plus value_flags, which defaults to ["output"]). An ignored flag that takes a value says so in value_flags. Also from the review: - min_count: 0 with no max_count is satisfied by every log, so the criterion could never fail; rejected alongside the other vacuities. - A missed positive reported only counts. Failure details now echo up to three recorded argvs, for a criterion whose purpose is "what did it actually run". - CLAUDE.md said 14 criterion types and omitted cli_called. - Documented that bundled short flags are not split (-rf is one flag named rf, so absent: true on f passes despite it). - Trimmed Field descriptions to behaviour; the rationale lives in comments. 68 criterion tests. Differential against the downstream suite still 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a9547d6 commit 607cd22

5 files changed

Lines changed: 126 additions & 89 deletions

File tree

‎CLAUDE.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ coder_eval/
3434
├── models/ # Pydantic data models (subpackage)
3535
│ ├── __init__.py # Unified exports for all models
3636
│ ├── enums.py # AgentKind, AgentState, FinalStatus, ApiBackend
37-
│ ├── criteria.py # 14 success criterion types + base + union
37+
│ ├── criteria.py # 15 success criterion types + base + union
3838
│ ├── experiment.py # ExperimentDefinition, ExperimentVariant, ResolvedTask, result models
3939
│ ├── judge_defaults.py # DEFAULT_JUDGE_MODEL constant (cycle-free leaf)
4040
│ ├── mutations.py # PromptMutation variants (prefix/suffix/replace/template)
@@ -144,7 +144,7 @@ action.yml # Published composite GitHub Action (coder-ev
144144
- **Run-time caps (non-criterion enforcement)**: `TaskDefinition.run_limits` (`RunLimits` model) is the single namespace for all run-time caps — `max_turns` / `task_timeout` / `turn_timeout` (structural) and `max_input_tokens` / `max_output_tokens` / `max_total_tokens` / `max_usd` (cumulative budget). Token/USD breaches abort with `FinalStatus.TOKEN_BUDGET_EXCEEDED` or `COST_BUDGET_EXCEEDED` (both `category == "failed"`). Structural caps are set from the CLI via `-D run_limits.max_turns=…` / `-D run_limits.task_timeout=…` / `-D run_limits.turn_timeout=…` (field-merged into `run_limits`); budget caps via `-D run_limits.max_usd=…` etc. or YAML. Layered config uses field-merge — a variant block overrides individual keys without replacing the task's block.
145145
- **Early stop on criterion (opt-in)**: `run_limits.stop_early` (default off) ends a single-shot Claude run early once the run's **armed** criteria are decided, so a raised `max_turns` isn't wasted on the smoke flavor. A criterion is armed by `stop_when: pass|fail|decided|auto`; only criteria that can decide from a partial trajectory may arm (non-empty `live_stop_polarities` ClassVar + `live_verdict` override — currently `skill_triggered`, `command_executed`; CE025 keeps the two consistent). `decided` arms **both** polarities; `auto` arms whichever polarities **this instance** can decide — the value for dataset-fanned criteria whose positive/distractor role flips per row. Stop rule: the pass-stop fires when every **pass-armed** criterion live-passes (fail-armed distractors are not required to pass; zero pass-armed ⇒ never pass-stops); the fail-stop fires on the first fail-armed live-fail but is **deferred while any pass-armed criterion is undecided** — a distractor misfire must not truncate a positive row's recall signal, so the latched misfire fires once the positives resolve (or the run continues to the cap). A fail-stop is therefore verdict-preserving; a pass-stop can miss a *later* distractor misfire, so authoritative P/R/F1 comes from a `stop_early: false` run. Driven by `orchestration/early_stop.py::EarlyStopWatcher` through the Claude agent's cooperative `should_stop` seam (tool-call granularity, no SIGKILL); live verdicts only *trigger* the stop — the standard `check_all_async` on the frozen trajectory is authoritative. An early-stopped run gates on the **armed subset** (`EvaluationResult.armed_criteria_passed`); a completed run gates on the full set. Every unsupported use is a hard error at resolution (plan *and* run), and a runtime verdict bug **fails open** to a full run. Surfaces: `EarlyStopInfo`, report notes/badges, `stopped_early` run.json rows, `EarlyStopped`/`EarlyStopReason` telemetry dims. Worked rationale: docs/TASK_DEFINITION_GUIDE.md § `stop_early`. Defaults off ⇒ behavior byte-for-behavior unchanged.
146146

147-
## Success Criteria (14 types)
147+
## Success Criteria (15 types)
148148

149149
| Type | Scoring | Description |
150150
|------|---------|-------------|
@@ -156,6 +156,7 @@ action.yml # Published composite GitHub Action (coder-ev
156156
| `file_matches_regex` | Binary | Regex match on file |
157157
| `reference_comparison` | Continuous | AST/token/complexity similarity |
158158
| `command_executed` | Fractional | Agent tool usage verification |
159+
| `cli_called` | Binary | Structured match over a JSON Lines invocation log: verb / positional / per-flag predicates, with min_count/max_count bounds |
159160
| `commands_efficiency` | Continuous | Agent tool-call efficiency relative to expected budget |
160161
| `uipath_eval` | Fractional | UiPath agent evaluation results |
161162
| `classification_match` | Binary | File-based label match (observed vs expected) with `(none)`/`(other)` sentinels; emits `ClassificationCriterionResult` for suite-level P/R/F1 |

‎docs/TASK_DEFINITION_GUIDE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,10 @@ Flags the criterion does not mention are ignored, so an extra `--output json` ne
822822
823823
Defaulting to "switch" is deliberate: `--yes` / `--force` / `-y` before the target is how destructive CLIs are invoked, so guessing that the flag swallows its neighbour is precisely how a `max_count: 0` guard ends up passing on the call it exists to forbid. The equals form (`--offset=-1`) is unambiguous and always binds directly, and a declared value flag binds even a dash-leading value (`--limit -1`).
824824

825+
`ignore_flags` drops a flag from matching but does **not** make it value-bearing — an ignored flag that takes a value must also appear in `value_flags` (as `output` does by default). Otherwise `ignore_flags: ["verbose"]` on `delete --verbose proj-1` would let `--verbose` eat `proj-1`.
826+
827+
**Limitation: bundled short flags are not split.** `-rf` parses as one flag named `rf`, so a predicate on `f` will not see it — including `absent: true`, which passes despite `-rf` being present. Assert on the long spelling, or add the bundled form via `aliases`. Likewise a bare negative number in flag position (`seek -1`) is read as a flag named `1`.
828+
825829
**Negative guards want the FEWEST facets that capture the forbidden act.** This is the opposite of a positive assertion, and it is easy to get backwards. `max_count: 0` passes when *nothing matches*, so every facet you add is another way for the real invocation to slip past the pattern and report a false PASS.
826830

827831
In the delete example above, it is tempting to also assert `--yes`. Don't:

‎src/coder_eval/criteria/cli_called.py‎

Lines changed: 31 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import logging
55
import re
6+
import shlex
67
from typing import TYPE_CHECKING, Any
78

89
from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
@@ -23,34 +24,17 @@ def _split_flags(
2324
) -> tuple[list[str], dict[str, list[str]]]:
2425
"""Split ``argv`` into non-flag arguments and a flag map.
2526
26-
Value binding is DECLARED, not guessed. ``value_flags`` names the flags that
27-
consume a following token; every other flag is a switch whose following token
28-
stays positional. The criterion supplies its own ``flags:`` keys plus
29-
``value_flags:`` as that set, so the author's assertion doubles as the grammar.
30-
31-
This replaced a heuristic ("the next token is the value unless it starts with
32-
``-``") that silently swallowed a positional after a boolean switch. For
33-
``uip fields delete --yes proj-1`` it bound ``yes=proj-1`` and dropped
34-
``proj-1`` from the positionals, so a ``max_count: 0`` guard on
35-
``positional: [proj-1]`` reported a PASS while the log proved the delete had
36-
happened. ``--yes``/``--force``/``-y`` before the target is how destructive
37-
CLIs are invoked, which is exactly the shape a negative guard exists to catch,
38-
so the default resolves ambiguity toward keeping the token positional.
39-
40-
Other normalizations, each a case a flat-string regex gets wrong:
41-
42-
- ``--flag=value`` binds directly. The equals form is unambiguous, so it is
43-
never re-run through any value/switch decision: ``--offset=-1`` keeps ``-1``
44-
instead of dropping it and inventing a flag named ``1``.
45-
- A declared value flag consumes its next token even when that token starts
46-
with ``-``, so ``--limit -1`` binds ``-1``.
47-
- Repeated flags accumulate, so ``--field a --field b`` keeps both values.
48-
- Names in ``ignore`` are dropped along with their values.
49-
50-
A bare ``--`` terminates flag parsing (POSIX convention): everything after it
51-
is positional, and the separator itself is dropped so it never has to be
52-
written into a ``positional:`` expectation. A lone ``-`` is positional too
53-
(the stdin convention), not a flag.
27+
Only flags in ``value_flags`` consume a following token; everything else is a
28+
switch. Guessing instead (``--yes proj-1`` binding ``yes=proj-1``) let a
29+
``max_count: 0`` guard pass on the delete it forbade, so ambiguity resolves
30+
toward keeping the token positional.
31+
32+
``--flag=value`` binds directly, being unambiguous. Repeated flags accumulate.
33+
``ignore`` names are dropped with their values. ``--`` ends flag parsing and is
34+
itself dropped; a lone ``-`` is positional.
35+
36+
Known limitation: bundled short flags are not split, so ``-rf`` is one flag
37+
named ``rf`` and a predicate on ``f`` will not see it.
5438
"""
5539
positional: list[str] = []
5640
flags: dict[str, list[str]] = {}
@@ -134,18 +118,14 @@ def _record_matches(criterion: CliCalledCriterion, argv: list[str], record: dict
134118
if criterion.tool is not None and record.get("tool") != criterion.tool:
135119
return False
136120

137-
# The criterion's own flag predicates declare which flags carry a value;
138-
# `value_flags` covers the rest (a flag whose value must not be mistaken for a
139-
# positional even though nothing asserts on it).
140-
ignore = frozenset(criterion.ignore_flags)
121+
# Declarations only. Folding `ignore_flags` in here made ignored SWITCHES
122+
# value-bearing, which swallowed the next positional and reopened the guard
123+
# false-PASS; an ignored flag that takes a value declares it in value_flags.
141124
positional, flags = _split_flags(
142125
argv,
143-
ignore,
144-
# Ignored flags are value-bearing too: dropping `--output` while leaving
145-
# `json` in the positionals would defeat the point of ignoring it.
126+
frozenset(criterion.ignore_flags),
146127
frozenset(n for name, p in (criterion.flags or {}).items() if p.needs_value for n in (name, *p.aliases))
147-
| frozenset(criterion.value_flags)
148-
| ignore,
128+
| frozenset(criterion.value_flags),
149129
)
150130

151131
offset = 0
@@ -165,9 +145,8 @@ def _record_matches(criterion: CliCalledCriterion, argv: list[str], record: dict
165145

166146
if criterion.flags:
167147
for name, predicate in criterion.flags.items():
168-
# One flag, several spellings: gather values across every name it owns.
169-
# [] means the flag was absent under all of them, which _flag_matches
170-
# distinguishes from "present with an empty value" (a switch, [""]).
148+
# [] means absent under every spelling, which _flag_matches
149+
# distinguishes from a switch's "present with empty value" ([""]).
171150
collected = [v for n in (name, *predicate.aliases) for v in flags.get(n, [])]
172151
if not _flag_matches(predicate, collected or None):
173152
return False
@@ -201,9 +180,8 @@ def _check_impl(
201180
Result with binary score (1.0 when the match count is within
202181
[min_count, max_count], 0.0 otherwise)
203182
"""
204-
# Compile every flag regex up front so a bad pattern reports itself as
205-
# such, instead of surfacing as a generic caught exception once the first
206-
# record happens to reach that predicate.
183+
# Up front so a bad pattern names its flag, rather than surfacing as a
184+
# generic caught exception when some record first reaches that predicate.
207185
for name, predicate in (criterion.flags or {}).items():
208186
if predicate.matches_regex is None:
209187
continue
@@ -218,10 +196,8 @@ def _check_impl(
218196
)
219197

220198
if not sandbox.file_exists(criterion.log):
221-
# A missing log is a harness fault, not agent behaviour: the mock never
222-
# ran or wrote elsewhere. Failing (rather than treating it as "zero
223-
# matching calls") is what stops a negative guard — max_count: 0 —
224-
# from passing vacuously against a log that does not exist.
199+
# Harness fault, not agent behaviour. Failing stops a max_count: 0
200+
# guard passing vacuously against a log that never existed.
225201
return CriterionResult(
226202
criterion_type=criterion.type,
227203
description=criterion.description,
@@ -252,12 +228,8 @@ def _check_impl(
252228
usable.append((argv, parsed))
253229

254230
if unusable:
255-
# Same footing as a missing log, and for the same reason: a record we
256-
# cannot read might BE the invocation a max_count: 0 guard forbids, so
257-
# scoring it as "did not match" would let the guard pass on the very
258-
# call it exists to catch. Skipping these silently (the previous
259-
# behaviour) contradicted the fail-loud missing-log path above and the
260-
# sibling precedent in json_check.
231+
# A record we cannot read might BE the call a max_count: 0 guard
232+
# forbids, so scoring it "did not match" would let the guard pass.
261233
logger.warning(
262234
f"cli_called: {unusable} unusable record(s) in '{criterion.log}'"
263235
+ " (unparseable line, non-object line, or argv that is not a list of strings)"
@@ -299,9 +271,14 @@ def _check_impl(
299271
if score == 1.0:
300272
details = f"{count} invocation(s) matched ({wanted}); satisfies {bound}"
301273
elif not within_lower:
274+
# A bare count sends the reader to the sandbox; this criterion exists
275+
# to answer "what did it actually run".
276+
sample = "; ".join(shlex.join(argv)[:120] for argv, _ in usable[:3])
277+
more = f" (+{len(usable) - 3} more)" if len(usable) > 3 else ""
278+
recorded = f" Recorded: {sample}{more}" if sample else ""
302279
details = (
303280
f"{count} invocation(s) matched ({wanted}); needs {bound}. "
304-
f"{len(records)} invocation(s) recorded in '{criterion.log}'"
281+
f"{len(records)} invocation(s) recorded in '{criterion.log}'.{recorded}"
305282
)
306283
else:
307284
details = f"{count} invocation(s) matched ({wanted}) but {bound} forbids it"

0 commit comments

Comments
 (0)