File indexing completed on 2026-08-12 08:24:58
0001 """Unit tests for DAG Executor.
0002
0003 Tests the DAGExecutor orchestration engine including:
0004 - Workflow execution with design points
0005 - Branch and stage execution
0006 - Context hierarchy (Branch → Stage → Job)
0007 - Evaluator selection and execution
0008 - XCom data passing
0009 - Topological sorting
0010 - Checkpoint logging
0011
0012 Project: AID2E v0.0.0 - AI assisted Detector Design for EIC
0013 """
0014
0015 import pytest
0016 import tempfile
0017 import shutil
0018 from pathlib import Path
0019 from typing import Dict, Any
0020
0021 from aid2e.utilities.workflows import (
0022 DAGExecutor,
0023 WorkflowDefinition,
0024 BranchDefinition,
0025 StageDefinition,
0026 JobDefinition,
0027 JobFactory,
0028 ParallelismPolicy,
0029 BashEvaluator,
0030 JobContext,
0031 )
0032 from aid2e.utilities.configurations import SchedulerConfiguration
0033 from aid2e.utilities.runtime_builders import build_workflow_executor_from_config
0034
0035
0036 class TestDAGExecutorBasics:
0037 """Test DAG Executor initialization and basic operations."""
0038
0039 def test_executor_initialization(self, tmp_path):
0040 """Test DAGExecutor initialization with minimal workflow."""
0041 workflow = WorkflowDefinition(
0042 name="test_workflow",
0043 branches=[],
0044 objectives=[],
0045 )
0046
0047 executor = DAGExecutor(
0048 workflow=workflow,
0049 base_output_dir=str(tmp_path),
0050 log_level="INFO",
0051 )
0052
0053 assert executor.workflow.name == "test_workflow"
0054 assert executor.output_dir.exists()
0055 assert executor.global_xcom == {}
0056 assert executor.logger is not None
0057
0058 def test_executor_output_directory_creation(self, tmp_path):
0059 """Test that executor creates unique output directories."""
0060 workflow = WorkflowDefinition(name="test_workflow", branches=[], objectives=[])
0061
0062 executor1 = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0063 executor2 = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0064
0065
0066 assert executor1.output_dir.exists()
0067 assert executor2.output_dir.exists()
0068
0069
0070
0071 class TestBranchExecution:
0072 """Test branch execution logic."""
0073
0074 def test_empty_workflow_execution(self, tmp_path):
0075 """Test executing workflow with no branches."""
0076 workflow = WorkflowDefinition(
0077 name="empty_workflow",
0078 branches=[],
0079 objectives=[],
0080 )
0081
0082 executor = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0083 design_point = {"x1": 0.5}
0084
0085
0086 objectives = executor.execute(design_point)
0087
0088 assert objectives == {}
0089
0090 def test_single_branch_with_one_stage(self, tmp_path):
0091 """Test executing single branch with one stage."""
0092 job = JobDefinition(
0093 name="test_job",
0094 command="echo 'Hello from job'",
0095 payload={"evaluator_type": "bash"},
0096 )
0097
0098 stage = StageDefinition(
0099 name="test_stage",
0100 jobs=[job],
0101 )
0102
0103 branch = BranchDefinition(
0104 name="main",
0105 stages=[stage],
0106 )
0107
0108 workflow = WorkflowDefinition(
0109 name="single_branch_workflow",
0110 branches=[branch],
0111 objectives=[],
0112 )
0113
0114 executor = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0115 design_point = {"x1": 0.5, "x2": 0.7}
0116
0117 objectives = executor.execute(design_point)
0118
0119
0120 assert objectives == {}
0121
0122
0123 assert len(executor.global_xcom) > 0
0124
0125
0126 class TestStageExecution:
0127 """Test stage execution with job expansion."""
0128
0129 def test_job_expansion_with_range_factory(self, tmp_path):
0130 """Test job expansion using range factory."""
0131 job = JobDefinition(
0132 name="parallel_job",
0133 command="echo 'Job execution'",
0134 payload={"evaluator_type": "bash"},
0135 )
0136
0137 stage = StageDefinition(
0138 name="parallel_stage",
0139 jobs=[job],
0140 job_factory=JobFactory(type="range", params={"n": 3}),
0141 )
0142
0143 branch = BranchDefinition(name="main", stages=[stage])
0144 workflow = WorkflowDefinition(name="parallel_workflow", branches=[branch], objectives=[])
0145
0146 executor = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0147
0148
0149 expanded_jobs = executor._expand_jobs(stage)
0150
0151 assert len(expanded_jobs) == 3
0152 assert expanded_jobs[0].name == "parallel_job_0"
0153 assert expanded_jobs[1].name == "parallel_job_1"
0154 assert expanded_jobs[2].name == "parallel_job_2"
0155
0156
0157 assert expanded_jobs[0].payload["job_index"] == 0
0158 assert expanded_jobs[1].payload["job_index"] == 1
0159 assert expanded_jobs[2].payload["job_index"] == 2
0160
0161 def test_stage_without_job_factory(self, tmp_path):
0162 """Test stage execution without job factory."""
0163 job1 = JobDefinition(name="job1", command="echo 'Job 1'", payload={})
0164 job2 = JobDefinition(name="job2", command="echo 'Job 2'", payload={})
0165
0166 stage = StageDefinition(
0167 name="multi_job_stage",
0168 jobs=[job1, job2],
0169 job_factory=None,
0170 )
0171
0172 executor = DAGExecutor(
0173 WorkflowDefinition(name="test", branches=[], objectives=[]),
0174 base_output_dir=str(tmp_path),
0175 )
0176
0177 expanded = executor._expand_jobs(stage)
0178
0179 assert len(expanded) == 2
0180 assert expanded[0].name == "job1"
0181 assert expanded[1].name == "job2"
0182
0183 def test_runtime_builder_resolves_stage_scheduler_cascade(self, tmp_path):
0184 """Stage scheduler overrides branch, workflow, and global schedulers."""
0185 global_scheduler = SchedulerConfiguration(
0186 runner_type="JobLibRunner",
0187 parameters={"n_jobs": 1},
0188 )
0189 workflow_scheduler = SchedulerConfiguration(
0190 runner_type="SlurmRunner",
0191 parameters={"ntasks": 1, "mem": "2G"},
0192 )
0193 branch_scheduler = SchedulerConfiguration(
0194 runner_type="JobLibRunner",
0195 parameters={"n_jobs": 3},
0196 )
0197 stage_scheduler = SchedulerConfiguration(
0198 runner_type="SlurmRunner",
0199 parameters={"ntasks": 1, "mem": "4G"},
0200 )
0201 stage = StageDefinition(
0202 name="stage_override",
0203 scheduler=stage_scheduler,
0204 jobs=[
0205 JobDefinition(
0206 name="job",
0207 command="echo test",
0208 payload={"evaluator_type": "bash"},
0209 )
0210 ],
0211 )
0212 branch_stage = StageDefinition(
0213 name="branch_default",
0214 jobs=[
0215 JobDefinition(
0216 name="job",
0217 command="echo test",
0218 payload={"evaluator_type": "bash"},
0219 )
0220 ],
0221 )
0222 branch = BranchDefinition(
0223 name="main",
0224 scheduler=branch_scheduler,
0225 stages=[stage, branch_stage],
0226 )
0227 workflow = WorkflowDefinition(
0228 name="cascade_workflow",
0229 scheduler=workflow_scheduler,
0230 branches=[branch],
0231 objectives=[],
0232 )
0233
0234 executor = build_workflow_executor_from_config(
0235 workflow,
0236 scheduler_cfg=global_scheduler,
0237 base_output_dir=str(tmp_path),
0238 )
0239
0240 resolved = {}
0241
0242 def capture_scheduler_config(stage, jobs, stage_context, design_point, scheduler_config):
0243 resolved[stage.name] = scheduler_config
0244
0245 executor._execute_stage_with_scheduler = capture_scheduler_config
0246 executor.execute({"x": 1.0})
0247
0248 assert resolved["stage_override"]["runner_type"] == "SlurmRunner"
0249 assert resolved["stage_override"]["config"].mem == "4G"
0250 assert resolved["branch_default"]["config"].n_jobs == 3
0251
0252
0253 class TestEvaluatorSelection:
0254 """Test evaluator selection logic."""
0255
0256 def test_bash_evaluator_selection(self, tmp_path):
0257 """Test that BashEvaluator is selected for bash jobs."""
0258 job = JobDefinition(
0259 name="bash_job",
0260 command="echo 'test'",
0261 payload={"evaluator_type": "bash"},
0262 )
0263
0264 executor = DAGExecutor(
0265 WorkflowDefinition(name="test", branches=[], objectives=[]),
0266 base_output_dir=str(tmp_path),
0267 )
0268
0269 evaluator = executor._create_evaluator(job, "test_job_id")
0270
0271 assert isinstance(evaluator, BashEvaluator)
0272 assert evaluator.engine_id == "test_job_id"
0273 assert evaluator.bash_command == "echo 'test'"
0274
0275 def test_container_evaluator_selection(self, tmp_path):
0276 """Test that ContainerEvaluator is selected for container jobs."""
0277 from aid2e.utilities.workflows import ContainerEvaluator
0278
0279 job = JobDefinition(
0280 name="container_job",
0281 command="python script.py",
0282 payload={
0283 "evaluator_type": "container",
0284 "image": "python:3.9",
0285 "container_command": ["/bin/bash", "-c", "python script.py"],
0286 "environment": {"ENV_VAR": "value"},
0287 },
0288 )
0289
0290 executor = DAGExecutor(
0291 WorkflowDefinition(name="test", branches=[], objectives=[]),
0292 base_output_dir=str(tmp_path),
0293 )
0294
0295 evaluator = executor._create_evaluator(job, "container_job_id")
0296
0297 assert isinstance(evaluator, ContainerEvaluator)
0298 assert evaluator.image == "python:3.9"
0299 assert evaluator.environment == {"ENV_VAR": "value"}
0300
0301 def test_default_to_bash_evaluator(self, tmp_path):
0302 """Test that jobs default to BashEvaluator if type not specified."""
0303 job = JobDefinition(
0304 name="default_job",
0305 command="ls -la",
0306 payload={},
0307 )
0308
0309 executor = DAGExecutor(
0310 WorkflowDefinition(name="test", branches=[], objectives=[]),
0311 base_output_dir=str(tmp_path),
0312 )
0313
0314 evaluator = executor._create_evaluator(job, "default_job_id")
0315
0316 assert isinstance(evaluator, BashEvaluator)
0317
0318
0319 class TestContextHierarchy:
0320 """Test context hierarchy creation and propagation."""
0321
0322 def test_branch_context_creation(self, tmp_path):
0323 """Test BranchContext creation with parameters."""
0324 from aid2e.utilities.workflows import BranchContext
0325
0326 branch = BranchDefinition(name="test_branch", stages=[])
0327
0328 executor = DAGExecutor(
0329 WorkflowDefinition(name="test", branches=[], objectives=[]),
0330 base_output_dir=str(tmp_path),
0331 )
0332
0333
0334 branch_context = BranchContext(
0335 branch_id=branch.name,
0336 parameters={},
0337 )
0338
0339 assert branch_context.branch_id == "test_branch"
0340 assert branch_context.parameters == {}
0341
0342 def test_stage_context_with_branch_parent(self, tmp_path):
0343 """Test StageContext with parent BranchContext."""
0344 from aid2e.utilities.workflows import BranchContext, StageContext
0345
0346 branch_context = BranchContext(
0347 branch_id="main",
0348 parameters={"branch_param": "value1"},
0349 )
0350
0351 stage = StageDefinition(
0352 name="test_stage",
0353 jobs=[],
0354 parallelism=ParallelismPolicy(max_concurrent=4, retry_max=2, timeout_sec=300),
0355 )
0356
0357 stage_context = StageContext(
0358 stage_id=stage.name,
0359 parameters=stage.parallelism.model_dump(),
0360 branch_context=branch_context,
0361 )
0362
0363 assert stage_context.stage_id == "test_stage"
0364 assert stage_context.parameters["max_concurrent"] == 4
0365 assert stage_context.branch_context.branch_id == "main"
0366
0367
0368 class TestDAGConstruction:
0369 """Test DAG construction from workflow stages."""
0370
0371 def test_dag_from_sequential_stages(self, tmp_path):
0372 """Test DAG construction from sequential stages."""
0373 stage1 = StageDefinition(name="stage1", jobs=[])
0374 stage2 = StageDefinition(name="stage2", jobs=[])
0375 stage3 = StageDefinition(name="stage3", jobs=[])
0376
0377 stages = [stage1, stage2, stage3]
0378
0379 executor = DAGExecutor(
0380 WorkflowDefinition(name="test", branches=[], objectives=[]),
0381 base_output_dir=str(tmp_path),
0382 )
0383
0384 dag = executor._build_dag_from_stages(stages, "test_branch")
0385
0386 assert len(dag.nodes) == 3
0387 assert dag.nodes[0].node_id == "stage1"
0388 assert dag.nodes[0].depends_on == []
0389
0390 assert dag.nodes[1].node_id == "stage2"
0391 assert dag.nodes[1].depends_on == ["stage1"]
0392
0393 assert dag.nodes[2].node_id == "stage3"
0394 assert dag.nodes[2].depends_on == ["stage2"]
0395
0396
0397 class TestObjectiveComputation:
0398 """Test objective computation from outputs."""
0399
0400 def test_objectives_from_xcom(self, tmp_path):
0401 """Test extracting objectives from XCom data."""
0402 from aid2e.utilities.configurations.objectives import ObjectiveDefinition, ObjectiveDirection
0403
0404 workflow = WorkflowDefinition(
0405 name="test",
0406 branches=[],
0407 objectives=[
0408 ObjectiveDefinition(name="f1", direction=ObjectiveDirection.MINIMIZE),
0409 ObjectiveDefinition(name="f2", direction=ObjectiveDirection.MINIMIZE),
0410 ]
0411 )
0412 executor = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0413
0414
0415 executor.global_xcom["job1:objectives"] = {"f1": 0.5, "f2": 0.8}
0416 executor.global_xcom["job2:result"] = "other output"
0417
0418 objectives = executor._compute_objectives()
0419
0420 assert objectives == {"f1": 0.5, "f2": 0.8}
0421
0422 def test_empty_objectives_when_no_xcom(self, tmp_path):
0423 """Test that empty objectives returned when no XCom data."""
0424 workflow = WorkflowDefinition(name="test", branches=[], objectives=[])
0425 executor = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0426
0427 objectives = executor._compute_objectives()
0428
0429 assert objectives == {}
0430
0431
0432 class TestEndToEndExecution:
0433 """End-to-end integration tests."""
0434
0435 def test_complete_workflow_execution(self, tmp_path):
0436 """Test complete workflow execution end-to-end."""
0437
0438 job = JobDefinition(
0439 name="eval_job",
0440 command="echo 'Evaluating design point'",
0441 payload={"evaluator_type": "bash"},
0442 )
0443
0444 stage = StageDefinition(
0445 name="evaluate",
0446 jobs=[job],
0447 job_factory=JobFactory(type="range", params={"n": 2}),
0448 )
0449
0450 branch = BranchDefinition(name="main", stages=[stage])
0451
0452 workflow = WorkflowDefinition(
0453 name="complete_workflow",
0454 branches=[branch],
0455 objectives=[],
0456 )
0457
0458 executor = DAGExecutor(workflow, base_output_dir=str(tmp_path))
0459 design_point = {"x1": 0.5, "x2": 0.7, "x3": 0.3}
0460
0461
0462 objectives = executor.execute(design_point)
0463
0464
0465 assert objectives == {}
0466
0467
0468 assert len(executor.global_xcom) >= 2
0469
0470
0471 assert executor.output_dir.exists()
0472 assert (executor.output_dir / "evaluate").exists()
0473 assert any((executor.output_dir / "evaluate").iterdir())
0474
0475
0476 job_dirs = list((executor.output_dir / "evaluate").glob("eval_job_*"))
0477 assert len(job_dirs) == 2
0478
0479
0480
0481 @pytest.fixture
0482 def tmp_path():
0483 """Create temporary directory for test outputs."""
0484 tmp_dir = Path(tempfile.mkdtemp())
0485 yield tmp_dir
0486 shutil.rmtree(tmp_dir)