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,837 @@
1
+ """Experiment orchestration — loading, config resolution, and aggregation.
2
+
3
+ Provides standalone functions for the 3-phase experiment pipeline:
4
+ 1. RESOLVE: resolve_all_tasks() — task_files x experiment -> list[ResolvedTask]
5
+ 2. EXECUTE: run_batch() (in batch.py) — list[ResolvedTask] -> list[TaskResult]
6
+ 3. AGGREGATE: aggregate_results() — list[TaskResult] -> ExperimentResult
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.resources
12
+ import logging
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ from ..models import (
20
+ AgentConfig,
21
+ BaseAgentConfig,
22
+ ConfigLineageEntry,
23
+ ExperimentDefinition,
24
+ ExperimentResult,
25
+ ExperimentVariant,
26
+ FinalStatus,
27
+ ResolvedTask,
28
+ RunLimits,
29
+ SimulationConfig,
30
+ SkippedTask,
31
+ TaskDefinition,
32
+ TaskExperimentSummary,
33
+ TaskResult,
34
+ TemplateSource,
35
+ VariantAggregate,
36
+ VariantResult,
37
+ apply_prompt_mutations,
38
+ )
39
+ from ..path_utils import build_task_run_dir
40
+ from .config import BatchRunConfig
41
+ from .config_merge import ConfigSource, Layer, merge_layers, resolve_root
42
+ from .task_loader import (
43
+ expand_dataset,
44
+ load_task,
45
+ resolve_agent_system_prompt,
46
+ resolve_template_source_paths,
47
+ resolve_variant_initial_prompt_file,
48
+ )
49
+
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ def _find_default_experiment() -> Path:
55
+ """Locate the default experiment YAML, checking packaged resources first.
56
+
57
+ Resolution order:
58
+ 1. Package resource (works in wheel installs)
59
+ 2. Repo-root fallback (works in source checkouts)
60
+ """
61
+ # 1. Package resource — always available in installed wheels
62
+ pkg_resource = importlib.resources.files("coder_eval.resources").joinpath("default_experiment.yaml")
63
+ pkg_path = Path(str(pkg_resource))
64
+ if pkg_path.is_file():
65
+ return pkg_path
66
+
67
+ # 2. Repo-root fallback — for source checkouts / editable installs
68
+ project_root = Path(__file__).resolve().parent.parent.parent.parent
69
+ repo_path = project_root / "experiments" / "default.yaml"
70
+ if repo_path.is_file():
71
+ return repo_path
72
+
73
+ # Return the package path as default (will produce a clear FileNotFoundError if missing)
74
+ return pkg_path
75
+
76
+
77
+ DEFAULT_EXPERIMENT_PATH = _find_default_experiment()
78
+
79
+
80
+ def _resolve_experiment_template_paths(experiment: ExperimentDefinition, base_dir: Path) -> None:
81
+ """Resolve relative TemplateDirSource paths in experiment defaults and variants.
82
+
83
+ Mutates paths in place, resolving relative paths against the experiment YAML directory.
84
+
85
+ Args:
86
+ experiment: The experiment definition to resolve paths in.
87
+ base_dir: Directory containing the experiment YAML file.
88
+ """
89
+ sources_lists: list[list[TemplateSource]] = []
90
+ if experiment.defaults and experiment.defaults.template_sources:
91
+ sources_lists.append(experiment.defaults.template_sources)
92
+ for variant in experiment.variants:
93
+ if variant.template_sources:
94
+ sources_lists.append(variant.template_sources)
95
+
96
+ for sources in sources_lists:
97
+ resolve_template_source_paths(sources, base_dir)
98
+
99
+
100
+ def load_experiment(experiment_file: Path) -> ExperimentDefinition:
101
+ """Load an experiment definition from a YAML file.
102
+
103
+ Args:
104
+ experiment_file: Path to the experiment YAML file.
105
+
106
+ Returns:
107
+ Parsed ExperimentDefinition.
108
+
109
+ Raises:
110
+ FileNotFoundError: If experiment file doesn't exist.
111
+ ValueError: If experiment file is invalid.
112
+ """
113
+ if not experiment_file.exists():
114
+ raise FileNotFoundError(f"Experiment file not found: {experiment_file}")
115
+
116
+ with open(experiment_file, encoding="utf-8") as f:
117
+ data = yaml.safe_load(f)
118
+
119
+ try:
120
+ exp = ExperimentDefinition(**data)
121
+ _resolve_experiment_template_paths(exp, experiment_file.parent)
122
+ return exp
123
+ except Exception as e:
124
+ raise ValueError(f"Invalid experiment definition: {e}") from e
125
+
126
+
127
+ def _resolve_simulation(
128
+ default_experiment: ExperimentDefinition,
129
+ experiment: ExperimentDefinition,
130
+ task: TaskDefinition,
131
+ variant: ExperimentVariant,
132
+ lineage: dict[str, ConfigLineageEntry],
133
+ ) -> SimulationConfig | None:
134
+ """Merge simulation config across the 4-layer precedence chain.
135
+
136
+ Precedence (lowest to highest):
137
+ 1. default_experiment.defaults.simulation
138
+ 2. experiment.defaults.simulation
139
+ 3. task.simulation
140
+ 4. variant.simulation
141
+
142
+ Any layer can be None (absent). When every layer is absent, the
143
+ resolved value is None — single-shot mode is preserved.
144
+
145
+ The merge runs through the generic resolver (``merge_layers`` over
146
+ ``SimulationConfig``): every field uses the type-aware default — scalars and
147
+ the ``constraints`` list all ``replace`` — so each layer's keys overwrite
148
+ earlier ones, matching the historical shallow merge. After merging, the dict
149
+ is validated by constructing a ``SimulationConfig`` (which raises a helpful
150
+ error if a required ``persona``/``goal`` was never supplied). Lineage stays
151
+ coarse: a single ``simulation`` entry crediting the most-specific source.
152
+ """
153
+ sim_specs: list[tuple[ConfigSource, dict[str, Any] | None]] = [
154
+ ("default", default_experiment.defaults.simulation if default_experiment.defaults else None),
155
+ ("experiment-defaults", experiment.defaults.simulation if experiment.defaults else None),
156
+ ("task", task.simulation.model_dump(exclude_unset=True) if task.simulation else None),
157
+ ("variant", variant.simulation),
158
+ ]
159
+ sim_layers = [Layer(source=src, patch=patch) for src, patch in sim_specs if patch is not None]
160
+ if not sim_layers:
161
+ return None
162
+
163
+ # No lineage passed → merge_layers emits no per-field entries; we record the
164
+ # single coarse `simulation` entry below instead.
165
+ merged = merge_layers((SimulationConfig,), sim_layers, lineage_root="simulation")
166
+ resolved = SimulationConfig(**merged)
167
+
168
+ most_specific: ConfigSource | None = next((src for src, patch in reversed(sim_specs) if patch is not None), None)
169
+ if most_specific is not None:
170
+ lineage["simulation"] = ConfigLineageEntry(value=merged, source=most_specific)
171
+
172
+ return resolved
173
+
174
+
175
+ def _resolve_repeats(
176
+ default_experiment: ExperimentDefinition,
177
+ experiment: ExperimentDefinition,
178
+ variant: ExperimentVariant,
179
+ config: BatchRunConfig,
180
+ lineage: dict[str, ConfigLineageEntry],
181
+ ) -> int:
182
+ """Resolve effective ``repeats`` for a (task, variant) via the 4-layer merge.
183
+
184
+ Skips task YAML (layer 3) — TaskDefinition has no repeats field.
185
+ Writes a ``"repeats"`` entry into ``lineage`` recording the winning source.
186
+ Raises ValueError when the resolved value exceeds 99 (2-digit subdir padding limit).
187
+ """
188
+ effective = 1
189
+ source: str = "default"
190
+
191
+ if default_experiment.defaults and default_experiment.defaults.repeats is not None:
192
+ effective = default_experiment.defaults.repeats
193
+ source = "default"
194
+ if experiment.defaults and experiment.defaults.repeats is not None:
195
+ effective = experiment.defaults.repeats
196
+ source = "experiment-defaults"
197
+ if variant.repeats is not None:
198
+ effective = variant.repeats
199
+ source = "variant"
200
+ if config.repeats is not None:
201
+ effective = config.repeats
202
+ source = "cli"
203
+
204
+ if effective > 99:
205
+ raise ValueError(
206
+ f"repeats must be <= 99 (got {effective}); widen replicate_subdir_name padding to support more"
207
+ )
208
+
209
+ lineage["repeats"] = ConfigLineageEntry(value=effective, source=source)
210
+ return effective
211
+
212
+
213
+ def _apply_prompt_overrides(
214
+ task: TaskDefinition,
215
+ experiment: ExperimentDefinition,
216
+ variant: ExperimentVariant,
217
+ lineage: dict[str, ConfigLineageEntry],
218
+ ) -> None:
219
+ """Apply variant-level prompt overrides or mutations to a resolved task. Mutates in place.
220
+
221
+ Resolution order:
222
+ 1. If variant.initial_prompt is set → full replacement (skip all mutations)
223
+ 2. Else → apply experiment.defaults.prompt_mutations, then variant.prompt_mutations
224
+
225
+ Args:
226
+ task: The resolved task definition (initial_prompt already inlined from task YAML).
227
+ experiment: The active experiment definition (for defaults.prompt_mutations).
228
+ variant: The specific variant being resolved.
229
+ lineage: Config lineage dict to update.
230
+ """
231
+ # Full replacement — skip all mutations
232
+ if variant.initial_prompt is not None:
233
+ task.initial_prompt = variant.initial_prompt
234
+ lineage["initial_prompt"] = ConfigLineageEntry(
235
+ value="(overridden)", source="variant", source_detail="initial_prompt override"
236
+ )
237
+ return
238
+
239
+ # Collect mutations: defaults first, then variant
240
+ defaults_mutations = (
241
+ list(experiment.defaults.prompt_mutations)
242
+ if experiment.defaults and experiment.defaults.prompt_mutations
243
+ else []
244
+ )
245
+ variant_mutations = list(variant.prompt_mutations) if variant.prompt_mutations else []
246
+ combined = defaults_mutations + variant_mutations
247
+
248
+ if not combined:
249
+ return
250
+
251
+ if task.initial_prompt is None:
252
+ raise ValueError(f"initial_prompt must be resolved before applying mutations (task '{task.task_id}')")
253
+
254
+ try:
255
+ task.initial_prompt = apply_prompt_mutations(task.initial_prompt, combined)
256
+ except re.error as e:
257
+ raise ValueError(
258
+ f"Invalid regex in prompt_mutations for variant '{variant.variant_id}' on task '{task.task_id}': {e}"
259
+ ) from e
260
+
261
+ # Build descriptive detail
262
+ type_names = [m.type for m in combined]
263
+ sources = []
264
+ if defaults_mutations:
265
+ sources.append("experiment-defaults")
266
+ if variant_mutations:
267
+ sources.append(f"variant '{variant.variant_id}'")
268
+ detail = f"{len(combined)} ops ({', '.join(type_names)}) from {' + '.join(sources)}"
269
+
270
+ lineage["initial_prompt"] = ConfigLineageEntry(value="(mutated)", source="mutation", source_detail=detail)
271
+
272
+
273
+ def _build_sandbox_layers(
274
+ default_experiment: ExperimentDefinition,
275
+ experiment: ExperimentDefinition,
276
+ task: TaskDefinition,
277
+ variant: ExperimentVariant,
278
+ ) -> list[Layer]:
279
+ """Build the ordered ``Layer`` list for the sandbox root (layers 1-4).
280
+
281
+ Constructs the sandbox layer list from default/exp-defaults/task SandboxConfig
282
+ dumps, plus synthetic layers for the experiment/variant TOP-LEVEL driver
283
+ (replace) and template_sources (append). `template_sources` is popped from
284
+ EVERY sandbox dump and re-added as dedicated synthetic layers, because its
285
+ append order is NOT layer order: per the field docs ("appended after task's
286
+ base templates") the task's own templates are the BASE and experiment-defaults
287
+ + variant are overlays appended after — i.e. task -> exp-defaults -> variant.
288
+ `driver` is kept in the dumps: the top-level `driver` field is layered AFTER,
289
+ so it overrides a nested `sandbox.driver` while a nested-only driver still
290
+ contributes. env_passthrough_extra appends in layer order via its strategy.
291
+
292
+ Returns the layer list; the caller passes it to ``resolve_root("sandbox", …)``.
293
+ """
294
+ sandbox_layers: list[Layer] = []
295
+ if default_experiment.defaults and default_experiment.defaults.sandbox:
296
+ p = default_experiment.defaults.sandbox.model_dump(exclude_unset=True)
297
+ p.pop("template_sources", None)
298
+ if p:
299
+ sandbox_layers.append(Layer(source="default", patch=p))
300
+ if experiment.defaults and experiment.defaults.sandbox:
301
+ p = experiment.defaults.sandbox.model_dump(exclude_unset=True)
302
+ p.pop("template_sources", None)
303
+ if p:
304
+ sandbox_layers.append(Layer(source="experiment-defaults", patch=p))
305
+ if experiment.defaults and experiment.defaults.driver is not None:
306
+ sandbox_layers.append(Layer(source="experiment-defaults", patch={"driver": experiment.defaults.driver}))
307
+ task_sandbox_patch = task.sandbox.model_dump(exclude_unset=True)
308
+ task_template_sources = task_sandbox_patch.pop("template_sources", None)
309
+ if task_sandbox_patch:
310
+ sandbox_layers.append(Layer(source="task", patch=task_sandbox_patch))
311
+ if variant.driver is not None:
312
+ sandbox_layers.append(Layer(source="variant", patch={"driver": variant.driver}, detail=variant.variant_id))
313
+ # template_sources synthetic layers, in documented task-first append order:
314
+ # task base, then experiment-defaults overlay, then variant overlay.
315
+ if task_template_sources:
316
+ sandbox_layers.append(Layer(source="task", patch={"template_sources": task_template_sources}))
317
+ if experiment.defaults and experiment.defaults.template_sources:
318
+ sandbox_layers.append(
319
+ Layer(
320
+ source="experiment-defaults",
321
+ patch={"template_sources": [s.model_dump() for s in experiment.defaults.template_sources]},
322
+ )
323
+ )
324
+ if variant.template_sources:
325
+ sandbox_layers.append(
326
+ Layer(source="variant", patch={"template_sources": [s.model_dump() for s in variant.template_sources]})
327
+ )
328
+ return sandbox_layers
329
+
330
+
331
+ def resolve_task_for_variant(
332
+ default_experiment: ExperimentDefinition,
333
+ task: TaskDefinition,
334
+ experiment: ExperimentDefinition,
335
+ variant: ExperimentVariant,
336
+ config: BatchRunConfig | None = None,
337
+ ) -> tuple[TaskDefinition, dict[str, ConfigLineageEntry], int]:
338
+ """Resolve a fully-configured TaskDefinition by merging the 4-layer precedence chain.
339
+
340
+ Precedence (lowest to highest):
341
+ 1. default_experiment.defaults.agent (global baseline defaults)
342
+ 2. experiment.defaults.agent (experiment-wide defaults, below task)
343
+ 3. task.agent (task-explicit fields only via exclude_unset)
344
+ 4. variant.agent (per-variant overrides, highest)
345
+
346
+ After resolution, CLI overrides (layer 5) are applied separately
347
+ by _apply_cli_overrides().
348
+
349
+ Args:
350
+ default_experiment: The default experiment (experiments/default.yaml).
351
+ task: The original task definition (may have agent=None).
352
+ experiment: The active experiment definition.
353
+ variant: The specific variant to resolve for.
354
+ config: Optional batch run config; used to resolve ``repeats``.
355
+
356
+ Returns:
357
+ Tuple of (resolved TaskDefinition, config lineage dict, effective_repeats).
358
+ """
359
+ # Layer 1-4 raw agent dicts. Experiment-side dicts are dict[str, Any] (passed
360
+ # through verbatim); the task agent is dumped with exclude_unset so Pydantic
361
+ # defaults don't leak into the merge. Timing (max_turns/turn_timeout) belongs
362
+ # under run_limits — a legacy max_turns under agent: now fails loudly via the
363
+ # agent model's extra="forbid" rather than being silently hoisted.
364
+ default_agent = default_experiment.defaults.agent if default_experiment.defaults else None
365
+ exp_defaults_agent = experiment.defaults.agent if experiment.defaults else None
366
+ variant_agent_clean = variant.agent
367
+ task_agent = task.agent.model_dump(exclude_unset=True) if task.agent else None
368
+
369
+ # Resolve all three `-D`-reachable roots through the SAME generic resolver
370
+ # the CLI layer uses (config_merge.resolve_root) — one merge implementation,
371
+ # one set of per-field strategies, lineage emitted as a side effect into the
372
+ # shared `lineage` dict. Type is enforced after CLI overrides (layer 5) so
373
+ # `--type` can satisfy the contract for tasks that omit `agent.type`.
374
+ lineage: dict[str, ConfigLineageEntry] = {}
375
+
376
+ # --- agent: layers default -> exp-defaults -> task -> variant ---
377
+ # No-op (agent: {type: none}) tasks need no special-casing here: `type` is a
378
+ # replace-scalar, so a task-level `type: none` wins over a baseline coding
379
+ # agent injected by the default experiment, and the merged config validates
380
+ # as NoneAgentConfig. The orchestrator then dispatches to NoOpAgent.
381
+ resolved_agent: AgentConfig | BaseAgentConfig | None
382
+ agent_layers: list[Layer] = []
383
+ agent_specs: list[tuple[ConfigSource, dict[str, Any] | None]] = [
384
+ ("default", default_agent),
385
+ ("experiment-defaults", exp_defaults_agent),
386
+ ("task", task_agent),
387
+ ("variant", variant_agent_clean),
388
+ ]
389
+ for source, patch in agent_specs:
390
+ if patch:
391
+ agent_layers.append(Layer(source=source, patch=patch))
392
+ resolved_agent = resolve_root("agent", agent_layers, lineage=lineage)
393
+ assert resolved_agent is not None # parse_agent_config always returns a model
394
+
395
+ # --- run_limits: the canonical RunLimits blocks across the 4 layers. ---
396
+ rl_layers: list[Layer] = []
397
+
398
+ def _add_rl(rl: RunLimits | None, source: ConfigSource) -> None:
399
+ if rl is None:
400
+ return
401
+ # exclude_unset (not exclude_none): count_cached_input defaults to False;
402
+ # exclude_none would write it back, clobbering a True from a lower layer.
403
+ patch = rl.model_dump(exclude_unset=True)
404
+ if patch:
405
+ rl_layers.append(Layer(source=source, patch=patch))
406
+
407
+ if default_experiment.defaults:
408
+ _add_rl(default_experiment.defaults.run_limits, "default")
409
+ if experiment.defaults:
410
+ _add_rl(experiment.defaults.run_limits, "experiment-defaults")
411
+ _add_rl(task.run_limits, "task")
412
+ _add_rl(variant.run_limits, "variant")
413
+ resolved_run_limits = resolve_root("run_limits", rl_layers, lineage=lineage)
414
+
415
+ # --- sandbox: synthetic-layer construction (driver precedence + task-first
416
+ # template_sources ordering) lives in _build_sandbox_layers. ---
417
+ sandbox_layers = _build_sandbox_layers(default_experiment, experiment, task, variant)
418
+ # resolve_root returns None only when no layer set anything; fall back to the
419
+ # task's own (default) sandbox so resolved_task.sandbox is never None.
420
+ resolved_sandbox = resolve_root("sandbox", sandbox_layers, lineage=lineage) or task.sandbox
421
+
422
+ # Resolve pre_run / post_run through the same engine via their declared append
423
+ # strategies: pre_run appends in layer order (exp-defaults setup first, then the
424
+ # task's); post_run uses append_order="reverse" (the task's commands first, then
425
+ # experiment-defaults cleanup-last). Only exp-defaults + task contribute.
426
+ prepost_layers: list[Layer] = []
427
+ exp_patch: dict[str, Any] = {}
428
+ if experiment.defaults and experiment.defaults.pre_run:
429
+ exp_patch["pre_run"] = list(experiment.defaults.pre_run)
430
+ if experiment.defaults and experiment.defaults.post_run:
431
+ exp_patch["post_run"] = list(experiment.defaults.post_run)
432
+ if exp_patch:
433
+ prepost_layers.append(Layer(source="experiment-defaults", patch=exp_patch))
434
+ task_patch: dict[str, Any] = {}
435
+ if task.pre_run:
436
+ task_patch["pre_run"] = list(task.pre_run)
437
+ if task.post_run:
438
+ task_patch["post_run"] = list(task.post_run)
439
+ if task_patch:
440
+ prepost_layers.append(Layer(source="task", patch=task_patch))
441
+ prepost = merge_layers((TaskDefinition,), prepost_layers, lineage_root="task") if prepost_layers else {}
442
+ resolved_pre_run = prepost.get("pre_run", [])
443
+ resolved_post_run = prepost.get("post_run", [])
444
+
445
+ # Resolve simulation: shallow-merge across default → experiment-defaults → task → variant.
446
+ # Mirrors agent merge semantics — a later layer's keys overwrite earlier ones, and
447
+ # the final dict is validated by building a SimulationConfig from it.
448
+ resolved_simulation = _resolve_simulation(default_experiment, experiment, task, variant, lineage)
449
+
450
+ # Build resolved task (copy with overrides)
451
+ resolved_task = task.model_copy(
452
+ update={
453
+ "agent": resolved_agent,
454
+ "run_limits": resolved_run_limits,
455
+ "sandbox": resolved_sandbox,
456
+ "post_run": resolved_post_run,
457
+ "pre_run": resolved_pre_run,
458
+ "simulation": resolved_simulation,
459
+ }
460
+ )
461
+
462
+ # Resolve repeats (4-layer: default → experiment-defaults → variant → cli; skips task layer)
463
+ _config = config if config is not None else BatchRunConfig(run_dir=Path("."))
464
+ effective_repeats = _resolve_repeats(default_experiment, experiment, variant, _config, lineage)
465
+
466
+ # When no config was supplied (direct callers / tests), enforce the agent.type
467
+ # contract here — _apply_cli_overrides won't run to do it later. A no-op task's
468
+ # `type: none` satisfies it (type is set), so only a truly type-less agent trips.
469
+ if config is None and resolved_agent is not None and resolved_agent.type is None:
470
+ raise ValueError(
471
+ "Agent 'type' is required but was not set by any layer (default experiment, "
472
+ + f"experiment defaults, task, or variant) for task {task.task_id!r}. "
473
+ + "Set it in the task YAML or the experiment."
474
+ )
475
+
476
+ return resolved_task, lineage, effective_repeats
477
+
478
+
479
+ def _apply_cli_overrides(
480
+ task: TaskDefinition,
481
+ config: BatchRunConfig,
482
+ lineage: dict[str, ConfigLineageEntry] | None = None,
483
+ ) -> None:
484
+ """Apply CLI overrides (layer 5) to a task definition in-place.
485
+
486
+ Layer 5 is CLI-only: ``-D``/``--set`` entries plus the bespoke flag
487
+ aliases. The actual merge + re-validation is delegated to
488
+ ``orchestration.overrides.apply_overrides``.
489
+
490
+ Args:
491
+ task: The task definition to mutate.
492
+ config: Batch run configuration containing CLI overrides.
493
+ lineage: Optional lineage dict to update with CLI override entries.
494
+ """
495
+ from .overrides import apply_overrides
496
+
497
+ # No-op (agent: {type: none}) tasks need no special-casing: the resolved agent
498
+ # is a NoneAgentConfig, so a suite-wide `--model` / `-D agent.*` lands on it
499
+ # harmlessly (NoOpAgent ignores them) and the `type: none` already satisfies the
500
+ # agent.type contract. An explicit `--type <x>` is highest precedence and, as for
501
+ # any task, replaces the type — turning the no-op task into that agent.
502
+ assert task.agent is not None, f"Task '{task.task_id}' has no agent config"
503
+
504
+ apply_overrides(task, config.overrides, agent_type=config.agent_type, lineage=lineage)
505
+
506
+ # Final guard: agent.type must be set after all 5 layers have merged.
507
+ if task.agent.type is None:
508
+ raise ValueError(
509
+ "Agent 'type' is required but was not set by any layer (default experiment, "
510
+ + f"experiment defaults, task, variant, or CLI) for task {task.task_id!r}. "
511
+ + "Set it in the task YAML, the experiment, or via --type."
512
+ )
513
+
514
+
515
+ def resolve_task_files(
516
+ task: TaskDefinition,
517
+ task_file: Path,
518
+ experiment_file: Path | None = None,
519
+ ) -> None:
520
+ """Resolve relative file paths injected by experiment variants.
521
+
522
+ Paths already resolved to absolute by load_task() are skipped.
523
+ New relative paths (from variant/base) resolve from experiment_file.parent.
524
+ """
525
+ exp_dir = experiment_file.parent if experiment_file is not None else task_file.parent
526
+
527
+ # Resolve system_prompt_file (may be injected by variant as relative or absolute path)
528
+ if task.agent is not None and task.agent.system_prompt_file is not None:
529
+ resolve_agent_system_prompt(task.agent, exp_dir)
530
+
531
+ # Resolve relative template_sources paths
532
+ if task.sandbox.template_sources:
533
+ resolve_template_source_paths(task.sandbox.template_sources, exp_dir)
534
+
535
+
536
+ def resolve_all_tasks(
537
+ task_files: list[Path],
538
+ experiment: ExperimentDefinition,
539
+ default_experiment: ExperimentDefinition,
540
+ config: BatchRunConfig,
541
+ experiment_file: Path | None = None,
542
+ ) -> tuple[list[ResolvedTask], list[SkippedTask]]:
543
+ """Resolve all (task x variant) combinations into typed, run-ready entries.
544
+
545
+ Applies all 5 config layers in one place:
546
+ 1. default experiment base
547
+ 2. task YAML
548
+ 3. experiment base
549
+ 4. variant overrides
550
+ 5. CLI overrides
551
+
552
+ Also handles tag filtering and unique task ID validation.
553
+
554
+ Task YAMLs that fail to load (YAML parse error, Pydantic validation,
555
+ dataset expansion error) are recorded in the returned ``skipped`` list
556
+ and excluded from the resolved set rather than aborting the suite. The
557
+ caller surfaces ``skipped`` in the run summary so the failure is loud
558
+ but recoverable.
559
+
560
+ Args:
561
+ task_files: Paths to task YAML files.
562
+ experiment: The active experiment definition.
563
+ default_experiment: The default experiment (experiments/default.yaml).
564
+ config: Batch run configuration (provides CLI overrides, tags, run_dir).
565
+ experiment_file: Path to the experiment YAML file. Used to resolve
566
+ relative paths injected by experiment variants. Falls back to task
567
+ file directory when None.
568
+
569
+ Returns:
570
+ Tuple of (resolved tasks ready for run_batch, skipped task records).
571
+
572
+ Raises:
573
+ ValueError: If duplicate task IDs are found after resolution.
574
+ """
575
+ resolved: list[ResolvedTask] = []
576
+ skipped: list[SkippedTask] = []
577
+
578
+ # Resolve variant-level initial_prompt_file paths before the main loop
579
+ exp_dir = experiment_file.parent if experiment_file is not None else None
580
+ for variant in experiment.variants:
581
+ if variant.initial_prompt_file is not None:
582
+ if exp_dir is None:
583
+ raise ValueError(
584
+ f"variant '{variant.variant_id}' uses initial_prompt_file but no experiment file path "
585
+ + "is available for resolving relative paths"
586
+ )
587
+ resolve_variant_initial_prompt_file(variant, exp_dir)
588
+
589
+ for task_file in task_files:
590
+ try:
591
+ task, source_yaml = load_task(task_file)
592
+ # Honor `skip: true` before dataset expansion — quarantined tasks
593
+ # skip row fan-out, variant resolution, and any further I/O. The
594
+ # task is reported in RunSummary.skipped_tasks so the suite shows
595
+ # which YAMLs were intentionally excluded vs. failed to load.
596
+ # Bypassed by --include-skipped (config.include_skipped) so on-demand /
597
+ # local runs can execute quarantined or opt-in tasks; the nightly/CI
598
+ # leave the flag off and keep excluding them.
599
+ if task.skip and not config.include_skipped:
600
+ reason = f"skip: true (task_id={task.task_id!r})"
601
+ logger.info("Skipping task %s — skip: true in YAML", task.task_id)
602
+ skipped.append(SkippedTask(path=str(task_file), reason=reason))
603
+ continue
604
+ # Dataset fan-out BEFORE variant resolution: one task per row, each
605
+ # treated as an independent task for the 4-layer merge below. This
606
+ # locks the invariant that variants cannot override the dataset.
607
+ expanded_tasks = expand_dataset(
608
+ task,
609
+ task_file.parent,
610
+ max_rows=config.max_rows,
611
+ sample_per_stratum=config.sample_per_stratum,
612
+ )
613
+ # Narrow set: real load failures only. We deliberately don't catch
614
+ # AttributeError / TypeError / ImportError — those signal a regression
615
+ # in load_task / expand_dataset and should crash loudly rather than
616
+ # silently demote every task to "skipped". Pydantic ValidationError
617
+ # is a ValueError subclass in v2, so it's covered.
618
+ except (FileNotFoundError, OSError, ValueError, yaml.YAMLError) as exc:
619
+ reason = f"{type(exc).__name__}: {exc}"[:500]
620
+ logger.warning("Skipping task file %s — %s", task_file, reason)
621
+ skipped.append(SkippedTask(path=str(task_file), reason=reason))
622
+ continue
623
+
624
+ for expanded_task in expanded_tasks:
625
+ for variant in experiment.variants:
626
+ # Apply layers 1-4 (default → experiment-defaults → task → variant) + resolve repeats
627
+ resolved_task, lineage, effective_repeats = resolve_task_for_variant(
628
+ default_experiment, expanded_task, experiment, variant, config
629
+ )
630
+
631
+ # Resolve file paths injected by variant overrides
632
+ resolve_task_files(resolved_task, task_file, experiment_file)
633
+
634
+ # Apply prompt mutations or overrides (between file resolution and CLI overrides)
635
+ _apply_prompt_overrides(resolved_task, experiment, variant, lineage)
636
+
637
+ # Apply layer 5 (CLI overrides)
638
+ _apply_cli_overrides(resolved_task, config, lineage)
639
+
640
+ # Fan-out: simulation n_trials takes precedence over experiment repeats
641
+ # when simulation is active; otherwise use experiment-level repeats.
642
+ sim = resolved_task.simulation
643
+ n_trials = sim.n_trials if (sim is not None and sim.enabled) else 1
644
+ fan_count = n_trials if n_trials > 1 else effective_repeats
645
+ for rep in range(fan_count):
646
+ resolved.append(
647
+ ResolvedTask(
648
+ task=resolved_task,
649
+ task_file=task_file,
650
+ run_dir=build_task_run_dir(
651
+ config.run_dir,
652
+ variant.variant_id,
653
+ resolved_task.task_id,
654
+ replicate_index=rep,
655
+ ),
656
+ variant_id=variant.variant_id,
657
+ replicate_index=rep,
658
+ source_yaml=source_yaml,
659
+ config_lineage=dict(lineage),
660
+ )
661
+ )
662
+
663
+ # Filter by tags
664
+ if config.include_tags or config.exclude_tags:
665
+ from .batch import filter_tasks_by_tags
666
+
667
+ tagged = [(rt.task_file, rt.task) for rt in resolved]
668
+ filtered = filter_tasks_by_tags(tagged, include_tags=config.include_tags, exclude_tags=config.exclude_tags)
669
+ filtered_ids = {t.task_id for _, t in filtered}
670
+ resolved = [rt for rt in resolved if rt.task.task_id in filtered_ids]
671
+
672
+ # Validate no duplicate (task_id, variant_id, replicate_index) combinations.
673
+ # Simulation replicates legitimately share (task_id, variant_id); the tuple
674
+ # is only a duplicate when the replicate_index also matches.
675
+ seen: dict[tuple[str, str, int], list[Path]] = {}
676
+ for rt in resolved:
677
+ key = (rt.task.task_id, rt.variant_id, rt.replicate_index)
678
+ seen.setdefault(key, []).append(rt.task_file)
679
+ duplicates = {k: files for k, files in seen.items() if len(files) > 1}
680
+ if duplicates:
681
+ lines = [
682
+ f" - '{tid}' (variant '{vid}', replicate {rep}): {', '.join(str(f) for f in files)}"
683
+ for (tid, vid, rep), files in duplicates.items()
684
+ ]
685
+ raise ValueError("Duplicate task IDs found:\n" + "\n".join(lines))
686
+
687
+ # Sort so tasks run interleaved: replicate 0 of every (task, variant) first,
688
+ # then replicate 1, etc. Within the same replicate, preserve original
689
+ # task-file and variant declaration order.
690
+ task_order = {tf: i for i, tf in enumerate(dict.fromkeys(rt.task_file for rt in resolved))}
691
+ variant_order = {v.variant_id: i for i, v in enumerate(experiment.variants)}
692
+ resolved.sort(key=lambda rt: (rt.replicate_index, task_order[rt.task_file], variant_order[rt.variant_id]))
693
+
694
+ return resolved, skipped
695
+
696
+
697
+ def _pick_worst_status(statuses: list[FinalStatus]) -> FinalStatus:
698
+ """Pick the worst final_status across replicates (error > failed > succeeded).
699
+
700
+ Unknown categories fall back to priority -1 so they sort as worst-of-all
701
+ (fail-closed: a new unrecognised status becomes the most urgent).
702
+ """
703
+ priority = {"error": 0, "failed": 1, "succeeded": 2}
704
+ return min(statuses, key=lambda s: priority.get(s.category, -1))
705
+
706
+
707
+ def _mean_reference_similarity(reps: list[TaskResult]) -> float | None:
708
+ """Return the mean reference_comparison score across replicates that have one."""
709
+ scores = [
710
+ cr.score
711
+ for r in reps
712
+ for cr in r.result.success_criteria_results
713
+ if cr.criterion_type == "reference_comparison"
714
+ ]
715
+ return sum(scores) / len(scores) if scores else None
716
+
717
+
718
+ def aggregate_results(
719
+ experiment_id: str,
720
+ description: str,
721
+ variant_ids: list[str],
722
+ task_results: list[TaskResult],
723
+ total_duration: float,
724
+ ) -> ExperimentResult:
725
+ """Aggregate typed task results into an ExperimentResult with cross-variant comparisons.
726
+
727
+ Replicates of the same (task_id, variant_id) are folded into a single
728
+ VariantResult whose weighted_score is the mean across replicates.
729
+ Per-replicate raw scores are preserved in ExperimentResult.per_replicate_scores
730
+ for statistical analysis.
731
+
732
+ Args:
733
+ experiment_id: Identifier for the experiment.
734
+ description: Human-readable description.
735
+ variant_ids: List of variant IDs in the experiment.
736
+ task_results: Typed results from run_batch execution.
737
+ total_duration: Total wall-clock duration in seconds.
738
+
739
+ Returns:
740
+ ExperimentResult with task summaries and variant aggregates.
741
+ """
742
+ # Group by (task_id, variant_id) — replicates of the same (task, variant) fold into one VariantResult.
743
+ task_variant_reps: dict[tuple[str, str], list[TaskResult]] = {}
744
+ for tr in task_results:
745
+ task_variant_reps.setdefault((tr.task_id, tr.variant_id), []).append(tr)
746
+
747
+ # Collect per-replicate scores keyed variant_id → task_id → [scores] for stats rendering.
748
+ per_replicate_scores: dict[str, dict[str, list[float]]] = {}
749
+ for (task_id, variant_id), reps in task_variant_reps.items():
750
+ per_replicate_scores.setdefault(variant_id, {})[task_id] = [r.result.weighted_score or 0.0 for r in reps]
751
+
752
+ task_variants: dict[str, list[VariantResult]] = {}
753
+ for (task_id, variant_id), reps in task_variant_reps.items():
754
+ scores = [r.result.weighted_score or 0.0 for r in reps]
755
+ non_errored = [r for r in reps if r.result.final_status.category != "error"]
756
+ durations = [r.result.duration_seconds for r in non_errored]
757
+ statuses = [r.result.final_status for r in reps]
758
+ iter_counts = [r.result.iteration_count for r in reps if r.result.iteration_count is not None]
759
+ asst_turns = [r.result.total_assistant_turns for r in reps if r.result.total_assistant_turns is not None]
760
+ token_vals = [r.result.total_token_usage.total_tokens for r in reps if r.result.total_token_usage is not None]
761
+ ref_similarity = _mean_reference_similarity(reps)
762
+ final_status = _pick_worst_status(statuses)
763
+
764
+ variant_result = VariantResult(
765
+ variant_id=variant_id,
766
+ task_id=task_id,
767
+ weighted_score=sum(scores) / len(scores),
768
+ final_status=final_status,
769
+ duration_seconds=sum(durations),
770
+ total_tokens=sum(token_vals) if token_vals else None,
771
+ iteration_count=round(sum(iter_counts) / len(iter_counts)) if iter_counts else None,
772
+ total_assistant_turns=round(sum(asst_turns) / len(asst_turns)) if asst_turns else None,
773
+ reference_similarity=ref_similarity,
774
+ replicate_index=0, # aggregate — points at first replicate for link rendering
775
+ replicate_count=len(reps),
776
+ )
777
+ task_variants.setdefault(task_id, []).append(variant_result)
778
+
779
+ # Build task summaries
780
+ task_summaries: list[TaskExperimentSummary] = []
781
+ for task_id, variants in task_variants.items():
782
+ best = max(variants, key=lambda v: (v.weighted_score, v.variant_id))
783
+ scores = [v.weighted_score for v in variants]
784
+ top_count = sum(1 for v in variants if v.weighted_score == best.weighted_score)
785
+ rep_counts = {v.replicate_count for v in variants}
786
+ task_summaries.append(
787
+ TaskExperimentSummary(
788
+ task_id=task_id,
789
+ variant_results=variants,
790
+ best_variant=best.variant_id,
791
+ is_tie=top_count > 1,
792
+ score_spread=max(scores) - min(scores),
793
+ replicate_count=min(rep_counts) if rep_counts else 1,
794
+ )
795
+ )
796
+
797
+ # Build variant aggregates
798
+ variant_aggregates: dict[str, VariantAggregate] = {}
799
+ for vid in variant_ids:
800
+ vr_list = [vr for ts in task_summaries for vr in ts.variant_results if vr.variant_id == vid]
801
+ if not vr_list:
802
+ variant_aggregates[vid] = VariantAggregate(
803
+ variant_id=vid,
804
+ tasks_run=0,
805
+ tasks_succeeded=0,
806
+ tasks_failed=0,
807
+ tasks_error=0,
808
+ average_score=0.0,
809
+ average_duration=0.0,
810
+ )
811
+ continue
812
+
813
+ token_values = [v.total_tokens for v in vr_list if v.total_tokens is not None]
814
+ total_tokens = sum(token_values) if token_values else None
815
+ variant_aggregates[vid] = VariantAggregate(
816
+ variant_id=vid,
817
+ tasks_run=len(vr_list),
818
+ tasks_succeeded=sum(1 for v in vr_list if v.final_status.category == "succeeded"),
819
+ tasks_failed=sum(1 for v in vr_list if v.final_status.category == "failed"),
820
+ tasks_error=sum(1 for v in vr_list if v.final_status.category == "error"),
821
+ tasks_token_budget_exceeded=sum(1 for v in vr_list if v.final_status == FinalStatus.TOKEN_BUDGET_EXCEEDED),
822
+ tasks_cost_budget_exceeded=sum(1 for v in vr_list if v.final_status == FinalStatus.COST_BUDGET_EXCEEDED),
823
+ average_score=sum(v.weighted_score for v in vr_list) / len(vr_list),
824
+ average_duration=sum(v.duration_seconds / v.replicate_count for v in vr_list) / len(vr_list),
825
+ total_tokens=total_tokens,
826
+ replicate_count=vr_list[0].replicate_count if vr_list else 1,
827
+ )
828
+
829
+ return ExperimentResult(
830
+ experiment_id=experiment_id,
831
+ description=description,
832
+ variant_ids=variant_ids,
833
+ task_summaries=task_summaries,
834
+ variant_aggregates=variant_aggregates,
835
+ total_duration_seconds=total_duration,
836
+ per_replicate_scores=per_replicate_scores,
837
+ )