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,119 @@
1
+ """Unified file check criterion checker (existence + contents + regex)."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
8
+ from coder_eval.models import CriterionResult, FileCheckCriterion
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from coder_eval.models.results import TurnRecord
13
+ from coder_eval.sandbox import Sandbox
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @register_criterion
19
+ class FileCheckChecker(BaseCriterion[FileCheckCriterion]):
20
+ """Checker for FileCheckCriterion."""
21
+
22
+ criterion_type = "file_check"
23
+
24
+ def _check_impl(
25
+ self,
26
+ criterion: FileCheckCriterion,
27
+ sandbox: "Sandbox",
28
+ reference_code: str | None = None,
29
+ *,
30
+ turn_records: list["TurnRecord"] | None = None,
31
+ context: CheckContext | None = None,
32
+ ) -> CriterionResult:
33
+ """Unified file check: existence, string includes/excludes, regex patterns.
34
+
35
+ Returns:
36
+ CriterionResult with fractional score based on active sub-checks
37
+ """
38
+ # 1. File existence (implicit)
39
+ if not sandbox.file_exists(criterion.path):
40
+ return CriterionResult(
41
+ criterion_type=criterion.type,
42
+ description=criterion.description,
43
+ score=0.0,
44
+ error=f"File '{criterion.path}' does not exist",
45
+ )
46
+
47
+ has_includes = len(criterion.includes) > 0
48
+ has_excludes = len(criterion.excludes) > 0
49
+ has_patterns = len(criterion.patterns) > 0
50
+
51
+ # 2. Pure existence check (no sub-checks specified)
52
+ if not has_includes and not has_excludes and not has_patterns:
53
+ return CriterionResult(
54
+ criterion_type=criterion.type,
55
+ description=criterion.description,
56
+ score=1.0,
57
+ details=f"File '{criterion.path}' exists",
58
+ )
59
+
60
+ # 3. Read file content
61
+ content = sandbox.get_file_content(criterion.path)
62
+
63
+ scores: list[float] = []
64
+ details_parts: list[str] = []
65
+
66
+ # 4a. Includes score
67
+ if has_includes:
68
+ found = sum(1 for inc in criterion.includes if inc in content)
69
+ total = len(criterion.includes)
70
+ includes_score = found / total
71
+ scores.append(includes_score)
72
+ details_parts.append(f"Includes: {found}/{total} found")
73
+
74
+ # 4b. Excludes score
75
+ if has_excludes:
76
+ present = sum(1 for exc in criterion.excludes if exc in content)
77
+ total = len(criterion.excludes)
78
+ excludes_score = 1.0 - (present / total)
79
+ scores.append(excludes_score)
80
+ absent = total - present
81
+ details_parts.append(f"Excludes: {absent}/{total} absent")
82
+
83
+ # 4c. Pattern scores
84
+ if has_patterns:
85
+ pattern_scores: list[float] = []
86
+ pattern_details: list[str] = []
87
+ for p in criterion.patterns:
88
+ try:
89
+ regex = re.compile(p.pattern, p.flags)
90
+ match = regex.search(content)
91
+ except re.error as e:
92
+ logger.warning("Invalid regex pattern '%s': %s", p.pattern, e)
93
+ pattern_scores.append(0.0)
94
+ pattern_details.append(f"Invalid regex '{p.pattern}': {e}")
95
+ continue
96
+
97
+ if p.must_match:
98
+ pattern_scores.append(1.0 if match else 0.0)
99
+ else:
100
+ pattern_scores.append(1.0 if match is None else 0.0)
101
+
102
+ matched = sum(1 for s in pattern_scores if s == 1.0)
103
+ patterns_score = sum(pattern_scores) / len(pattern_scores)
104
+ scores.append(patterns_score)
105
+ detail = f"Patterns: {matched}/{len(pattern_scores)} matched"
106
+ if pattern_details:
107
+ detail += f" ({'; '.join(pattern_details)})"
108
+ details_parts.append(detail)
109
+
110
+ # 5. Combine active category scores
111
+ score = sum(scores) / len(scores)
112
+ details_parts.append(f"Score: {score:.2f}")
113
+
114
+ return CriterionResult(
115
+ criterion_type=criterion.type,
116
+ description=criterion.description,
117
+ score=score,
118
+ details="; ".join(details_parts),
119
+ )
@@ -0,0 +1,85 @@
1
+ """File contains criterion checker."""
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
7
+ from coder_eval.models import CriterionResult, FileContainsCriterion
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
+ @register_criterion
18
+ class FileContainsChecker(BaseCriterion[FileContainsCriterion]):
19
+ """Checker for FileContainsCriterion."""
20
+
21
+ criterion_type = "file_contains"
22
+
23
+ def _check_impl(
24
+ self,
25
+ criterion: FileContainsCriterion,
26
+ sandbox: "Sandbox",
27
+ reference_code: str | None = None,
28
+ *,
29
+ turn_records: list["TurnRecord"] | None = None,
30
+ context: CheckContext | None = None,
31
+ ) -> CriterionResult:
32
+ """Check if file contains required strings and excludes forbidden ones.
33
+
34
+ Returns:
35
+ CriterionResult with fractional score based on includes/excludes matched
36
+ """
37
+ if not sandbox.file_exists(criterion.path):
38
+ return CriterionResult(
39
+ criterion_type=criterion.type,
40
+ description=criterion.description,
41
+ score=0.0,
42
+ error=f"File '{criterion.path}' does not exist",
43
+ )
44
+
45
+ content = sandbox.get_file_content(criterion.path)
46
+
47
+ # Calculate includes score (fraction of includes found)
48
+ includes_found = sum(1 for inc in criterion.includes if inc in content)
49
+ includes_total = len(criterion.includes)
50
+ includes_score = includes_found / includes_total if includes_total > 0 else 1.0
51
+
52
+ # Calculate excludes score (fraction of excludes absent)
53
+ if criterion.excludes:
54
+ excludes_found = sum(1 for exc in criterion.excludes if exc in content)
55
+ excludes_total = len(criterion.excludes)
56
+ excludes_score = 1.0 - (excludes_found / excludes_total)
57
+ else:
58
+ excludes_score = 1.0
59
+
60
+ # Combined score: only average when both categories are active
61
+ has_includes = includes_total > 0
62
+ has_excludes = bool(criterion.excludes)
63
+ if has_includes and has_excludes:
64
+ score = (includes_score + excludes_score) / 2.0
65
+ elif has_includes:
66
+ score = includes_score
67
+ elif has_excludes:
68
+ score = excludes_score
69
+ else:
70
+ score = 1.0
71
+
72
+ # Build details
73
+ details_parts = []
74
+ details_parts.append(f"Includes: {includes_found}/{includes_total} found")
75
+ if criterion.excludes:
76
+ excludes_absent = len(criterion.excludes) - sum(1 for exc in criterion.excludes if exc in content)
77
+ details_parts.append(f"Excludes: {excludes_absent}/{len(criterion.excludes)} absent")
78
+ details_parts.append(f"Score: {score:.2f}")
79
+
80
+ return CriterionResult(
81
+ criterion_type=criterion.type,
82
+ description=criterion.description,
83
+ score=score,
84
+ details="; ".join(details_parts),
85
+ )
@@ -0,0 +1,45 @@
1
+ """File existence criterion checker."""
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
7
+ from coder_eval.models import CriterionResult, FileExistsCriterion
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
+ @register_criterion
18
+ class FileExistsChecker(BaseCriterion[FileExistsCriterion]):
19
+ """Checker for FileExistsCriterion."""
20
+
21
+ criterion_type = "file_exists"
22
+
23
+ def _check_impl(
24
+ self,
25
+ criterion: FileExistsCriterion,
26
+ sandbox: "Sandbox",
27
+ reference_code: str | None = None,
28
+ *,
29
+ turn_records: list["TurnRecord"] | None = None,
30
+ context: CheckContext | None = None,
31
+ ) -> CriterionResult:
32
+ """Check if a file exists in the sandbox.
33
+
34
+ Returns:
35
+ CriterionResult with score 1.0 if exists, 0.0 if not
36
+ """
37
+ exists = sandbox.file_exists(criterion.path)
38
+ score = 1.0 if exists else 0.0
39
+
40
+ return CriterionResult(
41
+ criterion_type=criterion.type,
42
+ description=criterion.description,
43
+ score=score,
44
+ details=f"File '{criterion.path}' {'exists' if exists else 'does not exist'}",
45
+ )
@@ -0,0 +1,86 @@
1
+ """File matches regex criterion checker."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
8
+ from coder_eval.models import CriterionResult, FileMatchesRegexCriterion
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from coder_eval.models.results import TurnRecord
13
+ from coder_eval.sandbox import Sandbox
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @register_criterion
19
+ class FileMatchesRegexChecker(BaseCriterion[FileMatchesRegexCriterion]):
20
+ """Checker for FileMatchesRegexCriterion."""
21
+
22
+ criterion_type = "file_matches_regex"
23
+
24
+ def _check_impl(
25
+ self,
26
+ criterion: FileMatchesRegexCriterion,
27
+ sandbox: "Sandbox",
28
+ reference_code: str | None = None,
29
+ *,
30
+ turn_records: list["TurnRecord"] | None = None,
31
+ context: CheckContext | None = None,
32
+ ) -> CriterionResult:
33
+ """Check if file content matches the regex pattern.
34
+
35
+ Args:
36
+ criterion: File matches regex criterion
37
+ sandbox: Sandbox instance for file access
38
+ reference_code: Not used for this criterion
39
+
40
+ Returns:
41
+ Result with binary score (1.0 if pattern matches as expected, 0.0 otherwise)
42
+ """
43
+ if not sandbox.file_exists(criterion.path):
44
+ return CriterionResult(
45
+ criterion_type=criterion.type,
46
+ description=criterion.description,
47
+ score=0.0,
48
+ error=f"File '{criterion.path}' does not exist",
49
+ )
50
+
51
+ content = sandbox.get_file_content(criterion.path)
52
+
53
+ # Compile and search for pattern
54
+ try:
55
+ regex = re.compile(criterion.pattern, criterion.flags)
56
+ match = regex.search(content)
57
+ except re.error as e:
58
+ return CriterionResult(
59
+ criterion_type=criterion.type,
60
+ description=criterion.description,
61
+ score=0.0,
62
+ error=f"Invalid regex pattern: {e}",
63
+ )
64
+
65
+ # Check based on must_match flag
66
+ if criterion.must_match:
67
+ score = 1.0 if match is not None else 0.0
68
+ if match:
69
+ matched_text = match.group()[:100]
70
+ details = f"Pattern '{criterion.pattern}' found in file (matched: '{matched_text}')"
71
+ else:
72
+ details = f"Pattern '{criterion.pattern}' not found in file"
73
+ else:
74
+ score = 1.0 if match is None else 0.0
75
+ if match is None:
76
+ details = f"Pattern '{criterion.pattern}' correctly absent from file"
77
+ else:
78
+ matched_text = match.group()[:100]
79
+ details = f"Pattern '{criterion.pattern}' found but should not be present (matched: '{matched_text}')"
80
+
81
+ return CriterionResult(
82
+ criterion_type=criterion.type,
83
+ description=criterion.description,
84
+ score=score,
85
+ details=details,
86
+ )
@@ -0,0 +1,184 @@
1
+ """JSON check criterion checker (existence + parse + schema + JMESPath assertions)."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import jmespath
10
+ import jsonschema
11
+
12
+ from coder_eval.criteria.base import BaseCriterion, CheckContext, register_criterion
13
+ from coder_eval.models import CriterionResult, JMESPathAssertion, JsonCheckCriterion
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from coder_eval.models.results import TurnRecord
18
+ from coder_eval.sandbox import Sandbox
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # JSON type name -> Python type(s) mapping for the "type" operator.
23
+ # Task authors use JSON names (string, number, array, object, boolean, null).
24
+ _JSON_TYPE_MAP: dict[str, type | tuple[type, ...]] = {
25
+ "string": str,
26
+ "number": (int, float),
27
+ "boolean": bool,
28
+ "array": list,
29
+ "object": dict,
30
+ "null": type(None),
31
+ }
32
+
33
+ # Operator dispatch table. Each operator takes (actual, expected) and returns bool.
34
+ # All exceptions are caught per-assertion by the caller.
35
+ _OPERATORS: dict[str, Callable[[Any, Any], bool]] = {
36
+ "equals": lambda actual, expected: actual == expected,
37
+ "not_equals": lambda actual, expected: actual != expected,
38
+ "contains": lambda actual, expected: expected in actual,
39
+ "gt": lambda actual, expected: actual > expected,
40
+ "gte": lambda actual, expected: actual >= expected,
41
+ "lt": lambda actual, expected: actual < expected,
42
+ "lte": lambda actual, expected: actual <= expected,
43
+ "type": lambda actual, expected: (
44
+ expected in _JSON_TYPE_MAP
45
+ and (
46
+ (isinstance(actual, bool) and expected == "boolean")
47
+ or (not isinstance(actual, bool) and isinstance(actual, _JSON_TYPE_MAP[expected]))
48
+ )
49
+ ),
50
+ "regex": lambda actual, expected: bool(re.search(expected, actual)),
51
+ "exists": lambda actual, _: actual is not None,
52
+ }
53
+
54
+
55
+ @register_criterion
56
+ class JsonCheckChecker(BaseCriterion[JsonCheckCriterion]):
57
+ """Checker for JsonCheckCriterion."""
58
+
59
+ criterion_type = "json_check"
60
+
61
+ def _check_impl(
62
+ self,
63
+ criterion: JsonCheckCriterion,
64
+ sandbox: "Sandbox",
65
+ reference_code: str | None = None,
66
+ *,
67
+ turn_records: list["TurnRecord"] | None = None,
68
+ context: CheckContext | None = None,
69
+ ) -> CriterionResult:
70
+ """JSON check: existence, parse, schema validation, JMESPath assertions."""
71
+ # 1. File existence
72
+ if not sandbox.file_exists(criterion.path):
73
+ return CriterionResult(
74
+ criterion_type=criterion.type,
75
+ description=criterion.description,
76
+ score=0.0,
77
+ error=f"File '{criterion.path}' does not exist",
78
+ )
79
+
80
+ # 2. Parse JSON
81
+ content = sandbox.get_file_content(criterion.path)
82
+ try:
83
+ data = json.loads(content)
84
+ except (json.JSONDecodeError, ValueError) as e:
85
+ return CriterionResult(
86
+ criterion_type=criterion.type,
87
+ description=criterion.description,
88
+ score=0.0,
89
+ error=f"Invalid JSON in '{criterion.path}': {e}",
90
+ )
91
+
92
+ has_schema = criterion.json_schema is not None
93
+ has_assertions = len(criterion.assertions) > 0
94
+
95
+ # 3. Pure validity check
96
+ if not has_schema and not has_assertions:
97
+ return CriterionResult(
98
+ criterion_type=criterion.type,
99
+ description=criterion.description,
100
+ score=1.0,
101
+ details=f"'{criterion.path}' is valid JSON",
102
+ )
103
+
104
+ scores: list[float] = []
105
+ details_parts: list[str] = []
106
+
107
+ # 4. Schema validation (gates assertions — if schema fails, skip assertions)
108
+ if has_schema:
109
+ assert criterion.json_schema is not None # narrowing for pyright
110
+ schema_score, schema_detail = self._check_schema(criterion.json_schema, data, sandbox)
111
+ scores.append(schema_score)
112
+ details_parts.append(schema_detail)
113
+
114
+ if schema_score == 0.0 and has_assertions:
115
+ details_parts.append("Assertions: skipped (schema failed)")
116
+ score = 0.0
117
+ details_parts.append(f"Score: {score:.2f}")
118
+ return CriterionResult(
119
+ criterion_type=criterion.type,
120
+ description=criterion.description,
121
+ score=score,
122
+ details="; ".join(details_parts),
123
+ )
124
+
125
+ # 5. JMESPath assertions
126
+ if has_assertions:
127
+ assertions_score, assertions_detail = self._check_assertions(criterion.assertions, data)
128
+ scores.append(assertions_score)
129
+ details_parts.append(assertions_detail)
130
+
131
+ # 6. Average active categories
132
+ score = sum(scores) / len(scores)
133
+ details_parts.append(f"Score: {score:.2f}")
134
+
135
+ return CriterionResult(
136
+ criterion_type=criterion.type,
137
+ description=criterion.description,
138
+ score=score,
139
+ details="; ".join(details_parts),
140
+ )
141
+
142
+ def _check_schema(self, schema_path: str, data: Any, sandbox: "Sandbox") -> tuple[float, str]:
143
+ """Validate data against a JSON Schema file."""
144
+ if not sandbox.file_exists(schema_path):
145
+ return 0.0, f"Schema file '{schema_path}' not found"
146
+
147
+ schema_content = sandbox.get_file_content(schema_path)
148
+ try:
149
+ schema = json.loads(schema_content)
150
+ except (json.JSONDecodeError, ValueError) as e:
151
+ return 0.0, f"Invalid JSON in schema file '{schema_path}': {e}"
152
+
153
+ try:
154
+ jsonschema.validate(instance=data, schema=schema)
155
+ return 1.0, "Schema: valid"
156
+ except jsonschema.ValidationError as e:
157
+ return 0.0, f"Schema: invalid — {e.message}"
158
+ except jsonschema.SchemaError as e:
159
+ return 0.0, f"Schema: malformed schema — {e.message}"
160
+
161
+ def _check_assertions(self, assertions: list[JMESPathAssertion], data: Any) -> tuple[float, str]:
162
+ """Evaluate JMESPath assertions against parsed JSON data."""
163
+ passed = 0
164
+ total = len(assertions)
165
+ failed_details: list[str] = []
166
+
167
+ for assertion in assertions:
168
+ try:
169
+ actual = jmespath.search(assertion.expression, data)
170
+ op_func = _OPERATORS[assertion.operator]
171
+ if op_func(actual, assertion.expected):
172
+ passed += 1
173
+ else:
174
+ expr = assertion.expression
175
+ op = assertion.operator
176
+ failed_details.append(f"'{expr}' {op}: expected {assertion.expected!r}, got {actual!r}")
177
+ except Exception as e:
178
+ failed_details.append(f"'{assertion.expression}': error — {e}")
179
+
180
+ score = passed / total
181
+ detail = f"Assertions: {passed}/{total} passed"
182
+ if failed_details:
183
+ detail += f" ({'; '.join(failed_details)})"
184
+ return score, detail