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,206 @@
1
+ """Layer-5 ``-D path=value`` / ``--set`` task-config override engine.
2
+
3
+ The thin layer-5 wrapper over the generic resolver (:mod:`config_merge`). It
4
+
5
+ 1. parses a scalar value (YAML-safe, with the YAML-1.1 truthy-alias guard),
6
+ 2. groups dotted ``path=value`` overrides into one nested ``-D`` patch per
7
+ root (``agent`` / ``run_limits`` / ``sandbox``),
8
+ 3. calls :func:`config_merge.resolve_root` with a lineage-silent value seed
9
+ (the already-resolved model) + the ``-D`` patch layer, so a field merges
10
+ under exactly the same strategy at layer 5 as at the variant layer.
11
+
12
+ Path *validation* (did-you-mean on typos) lives in
13
+ :func:`config_merge.validate_paths`; value validation happens in the root
14
+ constructors during reconstruction.
15
+
16
+ Deliberately CLI-free (no ``typer`` import — lint rule CE004). The CLI boundary
17
+ wraps :class:`OverrideError` / :class:`config_merge.MergeError` into
18
+ ``typer.BadParameter``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Mapping
24
+ from typing import Any
25
+
26
+ import yaml
27
+ from pydantic import ValidationError
28
+
29
+ from ..models import AgentKind, ConfigLineageEntry, TaskDefinition
30
+ from .config_merge import ALLOWED_OVERRIDE_ROOTS, Layer, MergeError, RootName, resolve_root
31
+
32
+
33
+ # YAML 1.1 truthy aliases PyYAML coerces to bool. We keep them as strings so
34
+ # e.g. ``-D agent.model=on`` doesn't silently become ``True``. This is the single
35
+ # scalar-parsing implementation for all ``-D``/``--set`` values.
36
+ _YAML_TRUTHY_ALIASES = frozenset({"y", "n", "yes", "no", "on", "off"})
37
+
38
+
39
+ class OverrideError(ValueError):
40
+ """Raised for malformed or schema-invalid ``-D`` overrides."""
41
+
42
+
43
+ def parse_scalar(value: str) -> Any:
44
+ """YAML-safe-load a scalar, keeping YAML-1.1 truthy aliases as strings.
45
+
46
+ ``on``/``off``/``yes``/``no``/``y``/``n`` (case-insensitive) stay strings to
47
+ avoid the foot-gun where a string-valued field colliding with a YAML keyword
48
+ is silently turned into a bool. Explicit ``true``/``false`` (YAML 1.2
49
+ canonical) coerce normally. Raises :class:`OverrideError` on invalid YAML.
50
+ """
51
+ try:
52
+ parsed = yaml.safe_load(value)
53
+ except yaml.YAMLError as e:
54
+ raise OverrideError(f"value is not valid YAML: {e}") from e
55
+ if isinstance(parsed, bool) and value.strip().lower() in _YAML_TRUTHY_ALIASES:
56
+ parsed = value
57
+ return parsed
58
+
59
+
60
+ def parse_override(raw: str) -> tuple[str, Any]:
61
+ """Split ``path=value`` on the FIRST ``=`` into ``(path, parsed_value)``.
62
+
63
+ Raises :class:`OverrideError` when ``=`` is absent or the path is empty.
64
+ The value is coerced via :func:`parse_scalar`.
65
+ """
66
+ if "=" not in raw:
67
+ raise OverrideError(f"override must be PATH=VALUE, got: {raw!r}")
68
+ path, _, value = raw.partition("=")
69
+ path = path.strip()
70
+ if not path:
71
+ raise OverrideError(f"override path cannot be empty: {raw!r}")
72
+ return path, parse_scalar(value)
73
+
74
+
75
+ def _assign_nested(patch: dict[str, Any], segments: list[str], value: Any) -> None:
76
+ """Set ``patch[seg0][seg1]... = value``, creating intermediate dicts.
77
+
78
+ Merges multiple ``-D`` entries that share a prefix (e.g.
79
+ ``agent.sdk_options.effort`` and ``agent.sdk_options.max_thinking_tokens``)
80
+ into one nested patch.
81
+ """
82
+ cursor = patch
83
+ for seg in segments[:-1]:
84
+ nxt = cursor.get(seg)
85
+ if not isinstance(nxt, dict):
86
+ nxt = {}
87
+ cursor[seg] = nxt
88
+ cursor = nxt
89
+ cursor[segments[-1]] = value
90
+
91
+
92
+ def apply_overrides(
93
+ task: TaskDefinition,
94
+ overrides: Mapping[str, Any],
95
+ *,
96
+ agent_type: str | None = None,
97
+ lineage: dict[str, ConfigLineageEntry] | None = None,
98
+ ) -> None:
99
+ """Apply layer-5 overrides onto a resolved ``TaskDefinition`` in place.
100
+
101
+ Groups ``overrides`` (dotted ``path=value``) into one nested ``-D`` patch per
102
+ root, then resolves each affected root via :func:`config_merge.resolve_root`
103
+ with a **lineage-silent** value seed (the already-resolved model) so the
104
+ layers-1-4 provenance for fields ``-D`` doesn't touch is preserved. Each
105
+ ``-D`` field obeys its declared merge strategy (append fields append, etc.) —
106
+ the same rule the variant layer uses.
107
+
108
+ ``agent_type`` (from ``--type``) is injected into the agent patch's ``type``
109
+ via ``setdefault`` (an explicit ``-D agent.type`` wins) and records ``"--type"``
110
+ as its ``source_detail``; all other paths auto-derive ``"-D <path>"``.
111
+ Unknown roots/keys raise
112
+ :class:`OverrideError` (the latter re-raised from
113
+ :class:`config_merge.MergeError`).
114
+ """
115
+ agent_patch: dict[str, Any] = {}
116
+ rl_patch: dict[str, Any] = {}
117
+ sandbox_patch: dict[str, Any] = {}
118
+ by_root = {"agent": agent_patch, "run_limits": rl_patch, "sandbox": sandbox_patch}
119
+
120
+ for path, value in overrides.items():
121
+ root, _, rest = path.partition(".")
122
+ if root not in by_root:
123
+ raise OverrideError(f"unknown override root {root!r}; allowed roots: {', '.join(ALLOWED_OVERRIDE_ROOTS)}")
124
+ _assign_nested(by_root[root], rest.split("."), value)
125
+
126
+ agent_detail: Mapping[str, str] | None = None
127
+ if agent_type is not None and "type" not in agent_patch:
128
+ # `--type` injection (no explicit `-D agent.type`): record its provenance
129
+ # as "--type". An explicit `-D agent.type` keeps its own "-D" detail.
130
+ agent_patch["type"] = agent_type
131
+ agent_detail = {"agent.type": "--type"}
132
+
133
+ if agent_patch:
134
+ assert task.agent is not None, f"Task '{task.task_id}' has no agent config"
135
+ # Preserve the friendly "sdk_options only for claude-code" message before
136
+ # reconstruction, keyed on the type the agent is *becoming*.
137
+ if "sdk_options" in agent_patch:
138
+ becoming = agent_patch.get("type", task.agent.type)
139
+ type_value = becoming.value if isinstance(becoming, AgentKind) else becoming
140
+ if type_value != AgentKind.CLAUDE_CODE.value:
141
+ where = "no agent type is set" if type_value is None else f"agent type {type_value}"
142
+ raise OverrideError(
143
+ f"sdk_options cannot be used with {where}. This option is only supported for claude-code agents."
144
+ )
145
+ # Seed with only the explicitly-set fields (exclude_unset) so switching the
146
+ # agent subclass via --type doesn't drag subclass-only defaults into a model
147
+ # that forbids them.
148
+ task.agent = _resolve(
149
+ "agent", task.agent.model_dump(exclude_unset=True), agent_patch, detail=agent_detail, lineage=lineage
150
+ )
151
+
152
+ if rl_patch:
153
+ seed = task.run_limits.model_dump(exclude_unset=True) if task.run_limits else {}
154
+ task.run_limits = _resolve("run_limits", seed, rl_patch, detail=None, lineage=lineage)
155
+
156
+ if sandbox_patch:
157
+ # Full dump: the resolved sandbox already folded layers 1-4 (incl. the
158
+ # append-semantics env_passthrough_extra/template_sources merges), so the
159
+ # full dump is the authoritative value base to patch onto.
160
+ resolved = _resolve("sandbox", task.sandbox.model_dump(), sandbox_patch, detail=None, lineage=lineage)
161
+ assert resolved is not None # sandbox seed is always non-empty
162
+ task.sandbox = resolved
163
+
164
+
165
+ def _resolve(
166
+ root: RootName,
167
+ seed_patch: dict[str, Any],
168
+ cli_patch: dict[str, Any],
169
+ *,
170
+ detail: Mapping[str, str] | None,
171
+ lineage: dict[str, ConfigLineageEntry] | None,
172
+ ) -> Any:
173
+ """resolve_root over a lineage-silent seed + the recording ``-D`` layer.
174
+
175
+ Re-raises both failure modes as :class:`OverrideError` so the core stays
176
+ typer-free and both read alike at the CLI: a :class:`config_merge.MergeError`
177
+ (unknown ``-D`` key, with a did-you-mean) and a Pydantic
178
+ :class:`ValidationError` (a bad ``-D`` *value*, condensed to a path-prefixed
179
+ line instead of the raw multi-line dump + ``errors.pydantic.dev`` URL).
180
+ """
181
+ layers = [
182
+ Layer(source="cli", patch=seed_patch, record_lineage=False),
183
+ Layer(source="cli", patch=cli_patch, detail=detail),
184
+ ]
185
+ try:
186
+ return resolve_root(root, layers, lineage=lineage)
187
+ except MergeError as e:
188
+ raise OverrideError(str(e)) from e
189
+ except ValidationError as e:
190
+ raise OverrideError(_format_validation_error(root, e)) from e
191
+
192
+
193
+ def _format_validation_error(root: RootName, exc: ValidationError) -> str:
194
+ """Condense a Pydantic ``ValidationError`` into one path-prefixed ``-D`` line
195
+ per error, dropping the verbose type tags and ``errors.pydantic.dev`` URL so a
196
+ bad value reads like the (already-clean) unknown-key errors."""
197
+ parts: list[str] = []
198
+ for err in exc.errors():
199
+ leaf = ".".join(str(p) for p in err["loc"])
200
+ path = f"{root}.{leaf}" if leaf else root
201
+ msg = err.get("msg", "invalid value")
202
+ if "input" in err:
203
+ parts.append(f"-D {path}: {msg} (got {err['input']!r})")
204
+ else:
205
+ parts.append(f"-D {path}: {msg}")
206
+ return "; ".join(parts) if parts else f"invalid -D override for {root!r}: {exc}"
@@ -0,0 +1,437 @@
1
+ """Task definition loading and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+ from ..models import (
15
+ AgentConfig,
16
+ BaseAgentConfig,
17
+ Dataset,
18
+ ExperimentVariant,
19
+ TaskDefinition,
20
+ TemplateDirSource,
21
+ TemplateSource,
22
+ )
23
+
24
+
25
+ # Fixed seed for the CLI --sample (max_rows) uniform draw: a smoke sample should
26
+ # be reproducible run-to-run, just not first-path-biased like a raw slice.
27
+ _SMOKE_SAMPLE_SEED = 0
28
+
29
+ _ROW_VAR_PATTERN = re.compile(r"\$\{row\.([A-Za-z_][A-Za-z0-9_]*)\}")
30
+ _ROW_ID_PATTERN = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
31
+ _ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
32
+
33
+
34
+ def load_task(task_file: Path) -> tuple[TaskDefinition, str]:
35
+ """Load a task definition from a YAML file.
36
+
37
+ Args:
38
+ task_file: Path to the task YAML file
39
+
40
+ Returns:
41
+ Tuple of (parsed TaskDefinition, raw YAML text)
42
+
43
+ Raises:
44
+ FileNotFoundError: If task file doesn't exist
45
+ ValueError: If task file is invalid
46
+ """
47
+ if not task_file.exists():
48
+ raise FileNotFoundError(f"Task file not found: {task_file}")
49
+
50
+ if task_file.is_dir():
51
+ msg = (
52
+ f"Expected a YAML task file but got a directory: {task_file}\n"
53
+ f"Hint: use a glob pattern like '{task_file}/*.yaml' to select task files."
54
+ )
55
+ raise ValueError(msg)
56
+
57
+ raw_yaml = task_file.read_text(encoding="utf-8")
58
+ task_data = yaml.safe_load(raw_yaml)
59
+
60
+ try:
61
+ task = TaskDefinition(**task_data)
62
+ # Resolve relative template paths
63
+ task = resolve_template_paths(task, task_file.parent)
64
+ task = resolve_initial_prompt_file(task, task_file.parent)
65
+ task = resolve_system_prompt_files(task, task_file.parent)
66
+ task = resolve_dockerfile_path(task, task_file.parent)
67
+ return task, raw_yaml
68
+ except Exception as e:
69
+ raise ValueError(f"Invalid task definition: {e}") from e
70
+
71
+
72
+ def resolve_template_source_paths(sources: list[TemplateSource], base_dir: Path) -> None:
73
+ """Resolve TemplateDirSource paths to absolute, in place.
74
+
75
+ Expands $VAR / ${VAR} environment variables, then normalizes the path:
76
+ relative paths are resolved against ``base_dir``; absolute paths are
77
+ used as-is (but still go through ``Path(...)`` for string normalization).
78
+
79
+ Undefined env variables raise ``ValueError`` — a template directory is a
80
+ load-bearing config field and an unresolved variable would otherwise
81
+ surface as a cryptic "Template directory not found" error at sandbox
82
+ setup, far from the actual configuration mistake.
83
+
84
+ Scope: only environment variables (``$VAR`` / ``${VAR}``) are expanded
85
+ here. Dataset row substitution (``${row.field}`` in ``expand_dataset``)
86
+ runs over ``initial_prompt`` and ``success_criteria`` only — it does
87
+ NOT touch ``sandbox.template_sources``. The two regexes are disjoint
88
+ (env requires ``[A-Za-z_][A-Za-z0-9_]*``, row-var requires the dot)
89
+ but a ``${row.X}`` left inside a template path will not be substituted
90
+ and will fail at sandbox setup.
91
+
92
+ Skips non-TemplateDirSource entries.
93
+
94
+ Args:
95
+ sources: List of template sources (TemplateDirSource, RepoSource, etc.)
96
+ base_dir: Base directory for resolving relative paths.
97
+
98
+ Raises:
99
+ ValueError: If a ``TemplateDirSource.path`` references an undefined
100
+ environment variable.
101
+ """
102
+ for source in sources:
103
+ if isinstance(source, TemplateDirSource):
104
+ raw = source.path
105
+ undefined: list[str] = []
106
+ for match in _ENV_VAR_PATTERN.finditer(raw):
107
+ var_name = match.group(1) or match.group(2)
108
+ if var_name not in os.environ:
109
+ undefined.append(var_name)
110
+ if undefined:
111
+ names = ", ".join(f"${v}" for v in undefined)
112
+ msg = (
113
+ f"Template path {raw!r} references undefined environment variable(s): {names}. "
114
+ f"Set them before loading the task (e.g. in .env) so the template directory can be resolved."
115
+ )
116
+ raise ValueError(msg)
117
+ expanded = os.path.expandvars(raw)
118
+ template_path = Path(expanded)
119
+ if template_path.is_absolute():
120
+ source.path = str(template_path)
121
+ else:
122
+ source.path = str((base_dir / template_path).resolve())
123
+
124
+
125
+ def resolve_template_paths(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
126
+ """Resolve relative template paths to absolute paths.
127
+
128
+ Mutates TemplateDirSource.path in place. Other source types don't need resolution.
129
+
130
+ Args:
131
+ task: Task definition with possibly relative paths
132
+ base_dir: Directory containing the task YAML file
133
+
134
+ Returns:
135
+ Task with resolved absolute paths (modified in place)
136
+ """
137
+ if task.sandbox.template_sources:
138
+ resolve_template_source_paths(task.sandbox.template_sources, base_dir)
139
+
140
+ return task
141
+
142
+
143
+ def resolve_dockerfile_path(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
144
+ """Resolve ``sandbox.docker.dockerfile_path`` to an absolute path, in place.
145
+
146
+ When set, ``dockerfile_path`` is interpreted relative to the task YAML's
147
+ directory (``base_dir``), with ``$VAR`` / ``${VAR}`` environment variables
148
+ expanded first (mirroring :func:`resolve_template_source_paths`). The
149
+ resolved file must exist -- a missing Dockerfile is a configuration error
150
+ surfaced at load time rather than as an opaque ``docker build`` failure.
151
+
152
+ No-op when ``dockerfile_path`` is unset. Resolution runs regardless of the
153
+ configured ``driver`` so the absolute path stays stable even if a later
154
+ layer flips the driver to ``docker``.
155
+
156
+ Args:
157
+ task: Task definition possibly carrying a relative ``dockerfile_path``.
158
+ base_dir: Directory containing the task YAML file.
159
+
160
+ Returns:
161
+ The same task with an absolute ``dockerfile_path`` (modified in place).
162
+
163
+ Raises:
164
+ FileNotFoundError: If the resolved Dockerfile does not exist.
165
+ """
166
+ docker_cfg = task.sandbox.docker
167
+ raw = docker_cfg.dockerfile_path
168
+ if raw is None:
169
+ return task
170
+ dockerfile = Path(os.path.expandvars(raw))
171
+ if not dockerfile.is_absolute():
172
+ dockerfile = (base_dir / dockerfile).resolve()
173
+ if not dockerfile.is_file():
174
+ raise FileNotFoundError(f"Dockerfile not found: {dockerfile}")
175
+ docker_cfg.dockerfile_path = str(dockerfile)
176
+ return task
177
+
178
+
179
+ def resolve_initial_prompt_file(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
180
+ """Resolve initial_prompt_file to inline initial_prompt.
181
+
182
+ In simulation mode, both ``initial_prompt`` and ``initial_prompt_file`` may
183
+ be absent — the simulator generates the opening user utterance itself.
184
+ """
185
+ if task.initial_prompt_file is not None:
186
+ prompt_path = Path(task.initial_prompt_file)
187
+ if not prompt_path.is_absolute():
188
+ prompt_path = (base_dir / prompt_path).resolve()
189
+ if not prompt_path.exists():
190
+ raise FileNotFoundError(f"initial_prompt_file not found: {prompt_path}")
191
+ content = prompt_path.read_text(encoding="utf-8").strip()
192
+ # Clear file field BEFORE setting inline to avoid mutual-exclusivity validator
193
+ task.initial_prompt_file = None
194
+ task.initial_prompt = content
195
+ if task.initial_prompt is None:
196
+ in_simulation = task.simulation is not None and task.simulation.enabled
197
+ if not in_simulation and not task.is_none_agent:
198
+ raise ValueError(
199
+ "Either 'initial_prompt' or 'initial_prompt_file' must be set "
200
+ + "(unless 'simulation.enabled' is true, in which case the simulator generates the opener, "
201
+ + "or 'agent.type' is 'none', in which case no agent runs)"
202
+ )
203
+ return task
204
+
205
+
206
+ def resolve_variant_initial_prompt_file(variant: ExperimentVariant, base_dir: Path) -> None:
207
+ """Resolve initial_prompt_file on a variant to inline initial_prompt. Mutates in place.
208
+
209
+ Args:
210
+ variant: The experiment variant (may have initial_prompt_file set).
211
+ base_dir: Directory to resolve relative paths against (experiment YAML dir).
212
+
213
+ Raises:
214
+ FileNotFoundError: If the file doesn't exist.
215
+ """
216
+ if variant.initial_prompt_file is None:
217
+ return
218
+ prompt_path = Path(variant.initial_prompt_file)
219
+ if not prompt_path.is_absolute():
220
+ prompt_path = (base_dir / prompt_path).resolve()
221
+ if not prompt_path.exists():
222
+ raise FileNotFoundError(f"variant initial_prompt_file not found: {prompt_path}")
223
+ content = prompt_path.read_text(encoding="utf-8").strip()
224
+ # Clear file field BEFORE setting inline to avoid mutual-exclusivity validator
225
+ variant.initial_prompt_file = None
226
+ variant.initial_prompt = content
227
+
228
+
229
+ def resolve_agent_system_prompt(agent_config: AgentConfig | BaseAgentConfig | None, base_dir: Path) -> None:
230
+ """Resolve system_prompt_file to inline system_prompt. Mutates in place."""
231
+ if agent_config is None:
232
+ return
233
+ if agent_config.system_prompt_file is not None:
234
+ prompt_path = Path(agent_config.system_prompt_file)
235
+ if not prompt_path.is_absolute():
236
+ prompt_path = (base_dir / prompt_path).resolve()
237
+ if not prompt_path.exists():
238
+ raise FileNotFoundError(f"system_prompt_file not found: {prompt_path}")
239
+ content = prompt_path.read_text(encoding="utf-8").strip()
240
+ # Clear file field BEFORE setting inline to avoid mutual-exclusivity validator
241
+ agent_config.system_prompt_file = None
242
+ agent_config.system_prompt = content
243
+
244
+
245
+ def resolve_system_prompt_files(task: TaskDefinition, base_dir: Path) -> TaskDefinition:
246
+ """Resolve system_prompt_file on agent config."""
247
+ if task.agent is not None:
248
+ resolve_agent_system_prompt(task.agent, base_dir)
249
+ return task
250
+
251
+
252
+ def _load_jsonl(path: Path) -> list[dict[str, Any]]:
253
+ """Read a JSONL file into a list of dicts."""
254
+ if not path.exists():
255
+ raise FileNotFoundError(f"Dataset file not found: {path}")
256
+ rows: list[dict[str, Any]] = []
257
+ with path.open(encoding="utf-8") as f:
258
+ for line_num, raw_line in enumerate(f, start=1):
259
+ line = raw_line.strip()
260
+ if not line:
261
+ continue
262
+ try:
263
+ row = json.loads(line)
264
+ except json.JSONDecodeError as e:
265
+ raise ValueError(f"Dataset {path}: invalid JSON on line {line_num}: {e}") from e
266
+ if not isinstance(row, dict):
267
+ raise ValueError(f"Dataset {path}: row on line {line_num} is not a JSON object: {row!r}")
268
+ rows.append(row)
269
+ return rows
270
+
271
+
272
+ def _resolve_path(p: str, task_file_dir: Path) -> Path:
273
+ path = Path(p)
274
+ return path if path.is_absolute() else (task_file_dir / path).resolve()
275
+
276
+
277
+ def _load_dataset_rows(dataset: Dataset, task_file_dir: Path) -> list[dict[str, Any]]:
278
+ """Load dataset rows from inline list or one or more JSONL files."""
279
+ if dataset.rows is not None:
280
+ return [dict(r) for r in dataset.rows]
281
+
282
+ assert dataset.paths is not None # guaranteed by Dataset.check_source
283
+ rows: list[dict[str, Any]] = []
284
+ for p in dataset.paths:
285
+ rows.extend(_load_jsonl(_resolve_path(p, task_file_dir)))
286
+ return rows
287
+
288
+
289
+ def _stratified_sample(
290
+ rows: list[dict[str, Any]],
291
+ field: str,
292
+ n: int,
293
+ seed: int | None,
294
+ ) -> list[dict[str, Any]]:
295
+ """Randomly keep up to ``n`` rows per stratum, keyed on ``str(row[field])``.
296
+
297
+ Strata with <= n rows are taken whole. Output preserves first-seen stratum
298
+ order; within a sampled stratum, rows are in their drawn (random) order.
299
+ Rows missing ``field`` fall into the "" stratum (this is where the activation
300
+ dataset's shared negatives — ``expected_skill: ""`` — collect). ``seed=None``
301
+ uses a fresh nondeterministic RNG, so the draw differs every run.
302
+ """
303
+ rng = random.Random(seed)
304
+ groups: dict[str, list[dict[str, Any]]] = {}
305
+ for row in rows:
306
+ groups.setdefault(str(row.get(field, "")), []).append(row)
307
+ out: list[dict[str, Any]] = []
308
+ for grp in groups.values():
309
+ out.extend(grp if len(grp) <= n else rng.sample(grp, n))
310
+ return out
311
+
312
+
313
+ def _substitute_row_in_str(s: str, row: dict[str, Any]) -> str:
314
+ """Replace ${row.<field>} occurrences in s with scalar values from row."""
315
+
316
+ def replace(match: re.Match[str]) -> str:
317
+ key = match.group(1)
318
+ if key not in row:
319
+ raise KeyError(f"${{row.{key}}}: key not found (available: {sorted(row.keys())})")
320
+ value = row[key]
321
+ if isinstance(value, dict | list):
322
+ raise TypeError(
323
+ f"${{row.{key}}}: value must be a scalar (str/int/float/bool/None), got {type(value).__name__}"
324
+ )
325
+ return "" if value is None else str(value)
326
+
327
+ return _ROW_VAR_PATTERN.sub(replace, s)
328
+
329
+
330
+ def _substitute_row_in_tree(obj: Any, row: dict[str, Any]) -> Any:
331
+ """Walk a nested dict/list structure and substitute ${row.X} in every string leaf."""
332
+ if isinstance(obj, str):
333
+ return _substitute_row_in_str(obj, row)
334
+ if isinstance(obj, list):
335
+ return [_substitute_row_in_tree(x, row) for x in obj]
336
+ if isinstance(obj, dict):
337
+ return {k: _substitute_row_in_tree(v, row) for k, v in obj.items()}
338
+ return obj
339
+
340
+
341
+ def expand_dataset(
342
+ task: TaskDefinition,
343
+ task_file_dir: Path,
344
+ max_rows: int | None = None,
345
+ sample_per_stratum: int | None = None,
346
+ ) -> list[TaskDefinition]:
347
+ """Fan out a task with ``dataset:`` into one TaskDefinition per row.
348
+
349
+ Tasks without ``dataset:`` pass through unchanged as ``[task]``.
350
+
351
+ Each expanded task:
352
+ - has task_id rewritten to ``"<original_task_id>/<row_id>"``
353
+ - has ``dataset`` cleared (prevents re-expansion downstream)
354
+ - has ``${row.<field>}`` substituted in ``initial_prompt`` and in all
355
+ string leaves of ``success_criteria`` entries
356
+
357
+ Row ids are validated against a safe pattern so they're filesystem-safe
358
+ when used as directory names under the run_dir.
359
+
360
+ Args:
361
+ task: Task that may carry a dataset.
362
+ task_file_dir: Directory of the source task YAML (for resolving dataset.paths).
363
+ max_rows: Optional CLI cap on rows used (for cheap smoke runs). A
364
+ fixed-seed uniform-random N-row sample over the whole dataset
365
+ (reproducible, but unbiased across ``dataset.paths`` — unlike a raw
366
+ slice). When provided, overrides both ``sample_per_stratum`` args.
367
+ Absent it, ``sample_per_stratum`` (stratified random) applies.
368
+ sample_per_stratum: Optional CLI override (``--sample-per-stratum``) for
369
+ ``dataset.sample_per_stratum`` — keep up to N rows per stratum
370
+ (stratum = ``dataset.stratify_field``, default ``expected_skill``).
371
+ Lets a runner cap a stratified dataset without editing the task YAML
372
+ (the nightly activation suite uses this). Ignored when ``max_rows``
373
+ is set. When None, falls back to ``dataset.sample_per_stratum``.
374
+
375
+ Returns:
376
+ Expanded list of TaskDefinitions. Length is 1 when dataset is None.
377
+
378
+ Raises:
379
+ ValueError: Empty dataset, duplicate row ids, missing id_field, or
380
+ malformed row id.
381
+ FileNotFoundError: Dataset path does not exist.
382
+ """
383
+ if task.dataset is None:
384
+ return [task]
385
+
386
+ rows = _load_dataset_rows(task.dataset, task_file_dir)
387
+ if not rows:
388
+ raise ValueError(f"Dataset for task '{task.task_id}' is empty")
389
+
390
+ # Row selection precedence:
391
+ # 1. CLI --sample (max_rows): flat uniform-random N over the whole dataset.
392
+ # Fixed seed => reproducible across runs, but (unlike a first-N slice)
393
+ # unbiased across the concatenated dataset.paths.
394
+ # 2. sample_per_stratum: stratified random N-per-stratum. CLI
395
+ # --sample-per-stratum (the arg) overrides dataset.sample_per_stratum
396
+ # (the YAML), so a runner can cap a dataset without editing its task.
397
+ ds = task.dataset
398
+ n_per_stratum = sample_per_stratum if sample_per_stratum is not None else ds.sample_per_stratum
399
+ # Stratified sampling is seeded only by dataset.sample_seed. When that is None the sample is
400
+ # deliberately nondeterministic — re-drawn every run — regardless of whether the CLI
401
+ # --sample-per-stratum flag or the YAML supplied the count (see Dataset.sample_seed). The
402
+ # nightly activation suite relies on this to broaden coverage across runs.
403
+ stratum_seed = ds.sample_seed
404
+ if max_rows is not None and max_rows < len(rows):
405
+ rows = random.Random(_SMOKE_SAMPLE_SEED).sample(rows, max_rows)
406
+ elif max_rows is None and n_per_stratum is not None:
407
+ rows = _stratified_sample(rows, ds.stratify_field, n_per_stratum, stratum_seed)
408
+
409
+ id_field = task.dataset.id_field
410
+ seen_ids: set[str] = set()
411
+ expanded: list[TaskDefinition] = []
412
+
413
+ for i, row in enumerate(rows):
414
+ if id_field not in row:
415
+ raise ValueError(f"Dataset row {i} for task '{task.task_id}' missing id_field '{id_field}': {row}")
416
+ row_id = str(row[id_field])
417
+ if not _ROW_ID_PATTERN.match(row_id):
418
+ raise ValueError(
419
+ f"Dataset row id {row_id!r} must match {_ROW_ID_PATTERN.pattern}"
420
+ + " (letters, digits, underscore, hyphen, dot)"
421
+ )
422
+ if row_id in seen_ids:
423
+ raise ValueError(f"Duplicate dataset row id for task '{task.task_id}': {row_id!r}")
424
+ seen_ids.add(row_id)
425
+
426
+ data = task.model_dump(exclude_unset=True)
427
+ if isinstance(data.get("initial_prompt"), str):
428
+ data["initial_prompt"] = _substitute_row_in_str(data["initial_prompt"], row)
429
+ if isinstance(data.get("success_criteria"), list):
430
+ data["success_criteria"] = [_substitute_row_in_tree(c, row) for c in data["success_criteria"]]
431
+ data["suite_id"] = task.task_id
432
+ data["row_id"] = row_id
433
+ data["task_id"] = f"{task.task_id}/{row_id}"
434
+ data["dataset"] = None
435
+ expanded.append(TaskDefinition(**data))
436
+
437
+ return expanded