coder-eval 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,248 @@
1
+ """Base criterion checker interface with error handling."""
2
+
3
+ import logging
4
+ import os
5
+ import statistics
6
+ import traceback
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from functools import wraps
11
+ from typing import TYPE_CHECKING, Any, ClassVar
12
+
13
+ from coder_eval.errors import JudgeInfrastructureError
14
+ from coder_eval.models import BaseSuccessCriterion, CriterionAggregate, CriterionResult
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ from coder_eval.models.results import TurnRecord
21
+ from coder_eval.models.routing import ApiRoute
22
+ from coder_eval.sandbox import Sandbox
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CheckContext:
29
+ """Live run context forwarded to every criterion checker's ``_check_impl``.
30
+
31
+ Bundles the pieces of orchestrator state that judge-style criteria
32
+ (``llm_judge`` / ``agent_judge``) need to route their own LLM calls. Carries
33
+ a live object (the resolved ``route``), so it is NOT a ``coder_eval.models``
34
+ Pydantic model — it never gets serialized into a result record.
35
+
36
+ Non-judge checkers receive it too (uniform ``_check_impl`` signature) and
37
+ ignore it.
38
+ """
39
+
40
+ route: "ApiRoute | None" = None
41
+ reference_dir: "Path | None" = None
42
+
43
+
44
+ def handle_criterion_errors(func: Callable[..., CriterionResult]) -> Callable[..., CriterionResult]:
45
+ """Decorator to handle errors in criterion checkers.
46
+
47
+ Wraps checker methods to catch exceptions and return a failed
48
+ CriterionResult with error details instead of raising.
49
+
50
+ This is the CENTRALIZED error handling that was in evaluator.py.
51
+ """
52
+
53
+ @wraps(func)
54
+ def wrapper(
55
+ self: Any,
56
+ criterion: BaseSuccessCriterion,
57
+ sandbox: "Sandbox",
58
+ reference_code: str | None = None,
59
+ turn_records: list["TurnRecord"] | None = None,
60
+ context: "CheckContext | None" = None,
61
+ ) -> CriterionResult:
62
+ try:
63
+ return func(
64
+ self,
65
+ criterion,
66
+ sandbox,
67
+ reference_code,
68
+ turn_records=turn_records,
69
+ context=context,
70
+ )
71
+ except JudgeInfrastructureError:
72
+ # Judge infra failure is NOT an agent failure — do not score it 0.0.
73
+ # Propagates to Orchestrator.run()'s broad except → FinalStatus.ERROR.
74
+ raise
75
+ except Exception as e:
76
+ exc_info = f"{e.__class__.__name__}: {e}"
77
+ tb = ""
78
+ if os.getenv("CODER_EVAL_DEBUG") == "1":
79
+ tb = "\n" + "".join(traceback.format_exc(limit=5))
80
+
81
+ criterion_type = criterion.type
82
+ logger.error(
83
+ f"Error in {self.__class__.__name__}.check() for criterion type '{criterion_type}': {exc_info}",
84
+ exc_info=True, # Adds full stack trace to logs
85
+ )
86
+ return CriterionResult(
87
+ criterion_type=criterion_type,
88
+ description=criterion.description,
89
+ score=0.0,
90
+ details=f"Error during check: {exc_info}{tb}",
91
+ error=exc_info, # Include exception type and message
92
+ )
93
+
94
+ return wrapper
95
+
96
+
97
+ class BaseCriterion[C: BaseSuccessCriterion](ABC):
98
+ """Abstract base class for all criterion checkers.
99
+
100
+ Each criterion checker must:
101
+ 1. Define `criterion_type` as a ClassVar[str] matching the discriminator
102
+ 2. Implement `_check_impl()` with the actual checking logic
103
+
104
+ The `check()` method is final and applies centralized error handling.
105
+
106
+ Type parameter C binds the checker to its specific criterion model for
107
+ better IDE support and static type checking.
108
+
109
+ Example:
110
+ @register_criterion
111
+ class FileExistsChecker(BaseCriterion[FileExistsCriterion]):
112
+ criterion_type = "file_exists"
113
+
114
+ def _check_impl(
115
+ self,
116
+ criterion: FileExistsCriterion,
117
+ sandbox: Sandbox,
118
+ reference_code: str | None = None,
119
+ ) -> CriterionResult:
120
+ # Implementation here
121
+ pass
122
+ """
123
+
124
+ # Subclasses MUST define this as a class variable
125
+ criterion_type: ClassVar[str]
126
+
127
+ @handle_criterion_errors
128
+ def check(
129
+ self,
130
+ criterion: C,
131
+ sandbox: "Sandbox",
132
+ reference_code: str | None = None,
133
+ turn_records: list["TurnRecord"] | None = None,
134
+ context: "CheckContext | None" = None,
135
+ ) -> CriterionResult:
136
+ """Execute the criterion check with centralized error handling.
137
+
138
+ This method is FINAL - subclasses must NOT override it.
139
+ Implement _check_impl() instead.
140
+
141
+ Args:
142
+ criterion: The specific criterion definition (Pydantic model)
143
+ sandbox: Sandbox instance for file access and command execution
144
+ reference_code: Optional reference code string for comparison
145
+ turn_records: Optional list of turn records for command inspection
146
+ context: Optional :class:`CheckContext` carrying the live run state
147
+ (``route`` / ``reference_dir``) that judge criteria
148
+ (``agent_judge``, ``llm_judge``) consume. Non-judge checkers
149
+ accept the uniform signature and ignore it.
150
+
151
+ Returns:
152
+ CriterionResult with score (0.0-1.0), details, and error info
153
+ """
154
+ return self._check_impl(
155
+ criterion,
156
+ sandbox,
157
+ reference_code,
158
+ turn_records=turn_records,
159
+ context=context,
160
+ )
161
+
162
+ @abstractmethod
163
+ def _check_impl(
164
+ self,
165
+ criterion: C,
166
+ sandbox: "Sandbox",
167
+ reference_code: str | None = None,
168
+ *,
169
+ turn_records: list["TurnRecord"] | None = None,
170
+ context: "CheckContext | None" = None,
171
+ ) -> CriterionResult:
172
+ """Implement the actual criterion checking logic.
173
+
174
+ Subclasses override this method, NOT check(). Every checker shares this
175
+ uniform signature; non-judge checkers accept ``context`` and ignore it.
176
+
177
+ Args:
178
+ criterion: The specific criterion definition (Pydantic model)
179
+ sandbox: Sandbox instance for file access and command execution
180
+ reference_code: Optional reference code string for comparison
181
+ turn_records: Optional list of turn records for command inspection
182
+ context: Optional :class:`CheckContext` (route / reference_dir).
183
+ Consumed by ``llm_judge`` / ``agent_judge``; ignored by
184
+ the rest.
185
+
186
+ Returns:
187
+ CriterionResult with score (0.0-1.0), details, and error info
188
+
189
+ Raises:
190
+ Any exception - will be caught by @handle_criterion_errors decorator
191
+ """
192
+ pass
193
+
194
+ def aggregate(
195
+ self,
196
+ criterion: C,
197
+ per_row_results: list[CriterionResult],
198
+ ) -> CriterionAggregate | None:
199
+ """Across-row aggregate for dataset-backed tasks.
200
+
201
+ Default implementation emits summary statistics over the per-row scores:
202
+ ``count``, ``mean``, ``median``, ``std``, ``min``, ``max``. This gives
203
+ every criterion a thresholdable baseline for free (e.g. a ``file_exists``
204
+ task can gate on ``suite_thresholds: {mean: 0.9}``).
205
+
206
+ Returns ``None`` only when there are no per-row results (empty dataset).
207
+
208
+ Subclasses with richer signals (classification, per-label metrics, etc.)
209
+ should override and call ``super().aggregate(...)`` to inherit these
210
+ baseline stats, then merge their own metrics and details on top.
211
+ """
212
+ if not per_row_results:
213
+ return None
214
+
215
+ scores = [r.score for r in per_row_results]
216
+ std = statistics.pstdev(scores) if len(scores) > 1 else 0.0
217
+ metrics: dict[str, float] = {
218
+ "count": float(len(scores)),
219
+ "mean": statistics.fmean(scores),
220
+ "median": statistics.median(scores),
221
+ "std": std,
222
+ "min": min(scores),
223
+ "max": max(scores),
224
+ }
225
+ return CriterionAggregate(
226
+ criterion_type=criterion.type,
227
+ metrics=metrics,
228
+ threshold_checks=[],
229
+ passed=True,
230
+ details={},
231
+ )
232
+
233
+
234
+ # Decorator for registration (defined here to avoid circular imports)
235
+ def register_criterion(cls: type[BaseCriterion[Any]]) -> type[BaseCriterion[Any]]:
236
+ """Decorator to register a criterion checker.
237
+
238
+ Moved here from __init__.py to prevent circular import issues.
239
+
240
+ Usage:
241
+ @register_criterion
242
+ class MyChecker(BaseCriterion[MyCriterion]):
243
+ criterion_type = "my_type"
244
+ ...
245
+ """
246
+ from coder_eval.criteria import CriterionRegistry
247
+
248
+ return CriterionRegistry.register(cls)
@@ -0,0 +1,107 @@
1
+ """Classification-match criterion checker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from coder_eval.criteria._classification_aggregate import overlay_classification_metrics
9
+ from coder_eval.criteria.base import BaseCriterion, register_criterion
10
+ from coder_eval.models import (
11
+ ClassificationCriterionResult,
12
+ ClassificationMatchCriterion,
13
+ CriterionAggregate,
14
+ CriterionResult,
15
+ )
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from coder_eval.criteria.base import CheckContext
20
+ from coder_eval.models.results import TurnRecord
21
+ from coder_eval.sandbox import Sandbox
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Sentinels for the observed label — surfaced as their own classes in the
26
+ # suite rollup's confusion matrix so "didn't write anything" and "wrote
27
+ # something unrecognisable" are visible failure modes rather than vanishing.
28
+ _SENTINEL_NONE = "(none)"
29
+ _SENTINEL_OTHER = "(other)"
30
+
31
+
32
+ @register_criterion
33
+ class ClassificationMatchChecker(BaseCriterion[ClassificationMatchCriterion]):
34
+ """Checker for ClassificationMatchCriterion.
35
+
36
+ Reads a file written by the agent, extracts its single-line label, and
37
+ compares it to the ground-truth label. Populates ``observed_label`` and
38
+ ``expected_label`` on the result so the suite rollup can compute per-class
39
+ precision / recall / F1 and a confusion matrix.
40
+ """
41
+
42
+ criterion_type = "classification_match"
43
+
44
+ def _check_impl(
45
+ self,
46
+ criterion: ClassificationMatchCriterion,
47
+ sandbox: Sandbox,
48
+ reference_code: str | None = None,
49
+ *,
50
+ turn_records: list[TurnRecord] | None = None,
51
+ context: CheckContext | None = None,
52
+ ) -> CriterionResult:
53
+ expected = self._canonicalize(criterion.expected_label, criterion)
54
+
55
+ try:
56
+ raw = sandbox.get_file_content(criterion.path)
57
+ except FileNotFoundError:
58
+ observed = _SENTINEL_NONE
59
+ else:
60
+ content = raw.strip()
61
+ observed = self._canonicalize(content, criterion) if content else _SENTINEL_NONE
62
+ if observed not in {*criterion.allowed_labels, _SENTINEL_NONE}:
63
+ # Content didn't canonicalize to a known label.
64
+ observed = _SENTINEL_OTHER
65
+
66
+ score = 1.0 if observed == expected else 0.0
67
+ details = f"observed={observed!r}, expected={expected!r}"
68
+
69
+ return ClassificationCriterionResult(
70
+ criterion_type=criterion.type,
71
+ description=criterion.description,
72
+ score=score,
73
+ details=details,
74
+ observed_label=observed,
75
+ expected_label=expected,
76
+ )
77
+
78
+ @staticmethod
79
+ def _canonicalize(value: str, criterion: ClassificationMatchCriterion) -> str:
80
+ """Map a raw string to its canonical form in ``allowed_labels``.
81
+
82
+ When ``case_sensitive=False`` and the value matches an allowed label
83
+ up to case, return the canonical casing from ``allowed_labels``.
84
+ Otherwise return the value as-is; callers decide whether to replace
85
+ it with a sentinel.
86
+ """
87
+ value = value.strip()
88
+ if criterion.case_sensitive:
89
+ return value
90
+ lower_map = {label.lower(): label for label in criterion.allowed_labels}
91
+ return lower_map.get(value.lower(), value)
92
+
93
+ def aggregate(
94
+ self,
95
+ criterion: ClassificationMatchCriterion,
96
+ per_row_results: list[CriterionResult],
97
+ ) -> CriterionAggregate | None:
98
+ """Baseline stats (from super) + sklearn-style classification metrics.
99
+
100
+ Delegates the classification math to
101
+ ``overlay_classification_metrics`` so skill_triggered and any future
102
+ label-emitting criterion share one implementation.
103
+ """
104
+ base = super().aggregate(criterion, per_row_results)
105
+ if base is None:
106
+ return None
107
+ return overlay_classification_metrics(base, per_row_results)
@@ -0,0 +1,181 @@
1
+ """Command executed criterion checker."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
9
+ from coder_eval.models import CommandExecutedCriterion, CriterionResult
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from coder_eval.models.results import TurnRecord
14
+ from coder_eval.sandbox import Sandbox
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Limit regex search input length to mitigate ReDoS on large command strings
19
+ _MAX_PATTERN_SEARCH_LEN = 2000
20
+
21
+
22
+ @register_criterion
23
+ class CommandExecutedChecker(BaseCriterion[CommandExecutedCriterion]):
24
+ """Checker for CommandExecutedCriterion.
25
+
26
+ Inspects agent CommandTelemetry records to verify specific
27
+ tools/commands were used during evaluation.
28
+ """
29
+
30
+ criterion_type = "command_executed"
31
+
32
+ def _check_impl(
33
+ self,
34
+ criterion: CommandExecutedCriterion,
35
+ sandbox: "Sandbox",
36
+ reference_code: str | None = None,
37
+ *,
38
+ turn_records: list["TurnRecord"] | None = None,
39
+ context: CheckContext | None = None,
40
+ ) -> CriterionResult:
41
+ """Check if the agent executed commands matching the criterion filters.
42
+
43
+ Scoring is fractional: min(1.0, matching_count / min_count).
44
+
45
+ Args:
46
+ criterion: Command executed criterion with filters
47
+ sandbox: Sandbox instance (not used for this criterion)
48
+ reference_code: Not used for this criterion
49
+ turn_records: List of turn records containing command telemetry
50
+
51
+ Returns:
52
+ CriterionResult with fractional score based on matching commands
53
+ """
54
+ if turn_records is None:
55
+ return CriterionResult(
56
+ criterion_type=criterion.type,
57
+ description=criterion.description,
58
+ score=0.0,
59
+ details="No turn records available",
60
+ error="turn_records not provided to checker",
61
+ )
62
+
63
+ all_commands = [cmd for turn in turn_records for cmd in turn.commands]
64
+
65
+ # Note: do NOT short-circuit when ``all_commands`` is empty. A
66
+ # negative-assertion criterion (``min_count: 0`` + ``max_count: 0``)
67
+ # SHOULD pass here — zero commands trivially satisfies "must not
68
+ # call X". Falling through into the matching loop sets
69
+ # ``match_count = 0`` and the scoring branch handles both shapes.
70
+
71
+ # Compile regex patterns if provided.
72
+ # re.DOTALL so `.` matches newlines — agents commonly write multi-line bash
73
+ # commands using backslash line-continuation (e.g. `uip ... \\\n --body ...`),
74
+ # and patterns like `foo.*--body` must span those line breaks.
75
+ pattern: re.Pattern[str] | None = None
76
+ if criterion.command_pattern is not None:
77
+ try:
78
+ pattern = re.compile(criterion.command_pattern, re.DOTALL)
79
+ except re.error as e:
80
+ return CriterionResult(
81
+ criterion_type=criterion.type,
82
+ description=criterion.description,
83
+ score=0.0,
84
+ details=f"Invalid command_pattern regex: {e}",
85
+ error=f"Invalid regex: {e}",
86
+ )
87
+
88
+ exclude_re: re.Pattern[str] | None = None
89
+ if criterion.exclude_pattern is not None:
90
+ try:
91
+ exclude_re = re.compile(criterion.exclude_pattern, re.DOTALL)
92
+ except re.error as e:
93
+ return CriterionResult(
94
+ criterion_type=criterion.type,
95
+ description=criterion.description,
96
+ score=0.0,
97
+ details=f"Invalid exclude_pattern regex: {e}",
98
+ error=f"Invalid regex: {e}",
99
+ )
100
+
101
+ # Filter and count matching commands
102
+ matching_commands: list[str] = []
103
+ for cmd in all_commands:
104
+ # Filter by tool name
105
+ if criterion.tool_name is not None and cmd.tool_name != criterion.tool_name:
106
+ continue
107
+
108
+ # Filter by success status
109
+ if criterion.require_success and cmd.result_status != "success":
110
+ continue
111
+
112
+ # Extract text for pattern matching (Bash: command param; others: JSON-serialized params)
113
+ if cmd.tool_name == "Bash" and cmd.parameters.get("command"):
114
+ cmd_text = cmd.parameters["command"]
115
+ else:
116
+ cmd_text = json.dumps(cmd.parameters)
117
+
118
+ # Truncate to mitigate ReDoS on large command strings
119
+ if len(cmd_text) > _MAX_PATTERN_SEARCH_LEN:
120
+ cmd_text = cmd_text[:_MAX_PATTERN_SEARCH_LEN]
121
+
122
+ # Filter by command pattern
123
+ if pattern is not None and not pattern.search(cmd_text):
124
+ continue
125
+
126
+ # Apply exclusion pattern (skip commands matching the exclusion)
127
+ if exclude_re is not None and exclude_re.search(cmd_text):
128
+ continue
129
+
130
+ # Build a display label for the matched command
131
+ if cmd.tool_name == "Bash" and cmd.parameters.get("command"):
132
+ label = cmd.parameters["command"]
133
+ else:
134
+ label = f"{cmd.tool_name}({json.dumps(cmd.parameters)[:80]})"
135
+ matching_commands.append(label)
136
+
137
+ match_count = len(matching_commands)
138
+
139
+ # Score model:
140
+ # max_count is None → fractional towards min_count (legacy behavior).
141
+ # When min_count == 0, the criterion is trivially
142
+ # satisfied (no minimum to hit) and scores 1.0.
143
+ # max_count is set → binary in-range. Pass iff
144
+ # min_count <= match_count <= max_count.
145
+ # The "negative assertion" pattern (min_count: 0, max_count: 0) drops
146
+ # naturally out of the binary branch — it now expresses "must NOT match"
147
+ # exactly. The model validator already rejects max_count < min_count.
148
+ if criterion.max_count is None:
149
+ score = 1.0 if criterion.min_count == 0 else min(1.0, match_count / criterion.min_count)
150
+ else:
151
+ score = 1.0 if criterion.min_count <= match_count <= criterion.max_count else 0.0
152
+
153
+ # Build details
154
+ filters = []
155
+ if criterion.tool_name is not None:
156
+ filters.append(f"tool_name={criterion.tool_name}")
157
+ if criterion.command_pattern is not None:
158
+ filters.append(f"pattern=/{criterion.command_pattern}/")
159
+ if criterion.require_success:
160
+ filters.append("require_success=True")
161
+ if criterion.exclude_pattern is not None:
162
+ filters.append(f"exclude=/{criterion.exclude_pattern}/")
163
+ filter_text = ", ".join(filters) if filters else "none"
164
+
165
+ if criterion.max_count is None:
166
+ range_text = f"{match_count}/{criterion.min_count} required"
167
+ else:
168
+ range_text = f"{match_count} matches (allowed range {criterion.min_count}..{criterion.max_count})"
169
+ details = f"Matched {range_text} commands (filters: {filter_text})"
170
+ if matching_commands:
171
+ # Show up to 3 example matches
172
+ examples = matching_commands[:3]
173
+ truncated = [ex[:120] for ex in examples]
174
+ details += f"\nExamples: {truncated}"
175
+
176
+ return CriterionResult(
177
+ criterion_type=criterion.type,
178
+ description=criterion.description,
179
+ score=score,
180
+ details=details,
181
+ )
@@ -0,0 +1,73 @@
1
+ """Commands efficiency criterion checker."""
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, ClassVar
5
+
6
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
7
+ from coder_eval.models import CommandsEfficiencyCriterion, CriterionResult
8
+
9
+
10
+ if TYPE_CHECKING:
11
+ from coder_eval.models.results import TurnRecord
12
+ from coder_eval.sandbox import Sandbox
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def compute_commands_efficiency(actual: int, expected: int) -> float:
18
+ """Compute commands efficiency score.
19
+
20
+ Returns expected / max(actual, expected), or 0.0 if actual is 0.
21
+ Raises ValueError if expected < 1 (callers must validate).
22
+ """
23
+ if expected < 1:
24
+ raise ValueError(f"expected must be >= 1, got {expected}")
25
+ if actual == 0:
26
+ return 0.0
27
+ return expected / max(actual, expected)
28
+
29
+
30
+ @register_criterion
31
+ class CommandsEfficiencyChecker(BaseCriterion[CommandsEfficiencyCriterion]):
32
+ """Checker for CommandsEfficiencyCriterion."""
33
+
34
+ criterion_type: ClassVar[str] = "commands_efficiency"
35
+
36
+ def _check_impl(
37
+ self,
38
+ criterion: CommandsEfficiencyCriterion,
39
+ sandbox: "Sandbox",
40
+ reference_code: str | None = None,
41
+ *,
42
+ turn_records: list["TurnRecord"] | None = None,
43
+ context: CheckContext | None = None,
44
+ ) -> CriterionResult:
45
+ if turn_records is None:
46
+ return CriterionResult(
47
+ criterion_type=criterion.type,
48
+ description=criterion.description,
49
+ score=0.0,
50
+ error="turn_records not provided to checker",
51
+ details="No turn records available",
52
+ )
53
+
54
+ actual = sum(len(t.commands) for t in turn_records)
55
+ if actual == 0:
56
+ return CriterionResult(
57
+ criterion_type=criterion.type,
58
+ description=criterion.description,
59
+ score=0.0,
60
+ details="No commands recorded (agent may not have run)",
61
+ )
62
+
63
+ score = compute_commands_efficiency(actual, criterion.expected_commands)
64
+ over = actual - criterion.expected_commands
65
+ budget_note = " (at or under budget)" if over <= 0 else f" (over budget by {over} commands)"
66
+ details = f"Commands: {actual}, expected: {criterion.expected_commands}, efficiency: {score:.3f}{budget_note}"
67
+
68
+ return CriterionResult(
69
+ criterion_type=criterion.type,
70
+ description=criterion.description,
71
+ score=score,
72
+ details=details,
73
+ )