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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|