Back to home page

EIC code displayed by LXR

 
 

    


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         # Should create different directories (timestamp-based)
0066         assert executor1.output_dir.exists()
0067         assert executor2.output_dir.exists()
0068         # They might be the same if created in same second, so just check they exist
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         # Should complete without error (no branches to execute)
0086         objectives = executor.execute(design_point)
0087         
0088         assert objectives == {}  # No objectives computed
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         # Should complete successfully
0120         assert objectives == {}  # No objectives defined
0121         
0122         # Check that job was executed (XCom should have entry)
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         # Test job expansion
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         # Check job_index in payload
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={},  # No evaluator_type specified
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         # Simulate branch context creation (from _execute_branch)
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 == []  # First stage has no deps
0389         
0390         assert dag.nodes[1].node_id == "stage2"
0391         assert dag.nodes[1].depends_on == ["stage1"]  # Depends on stage1
0392         
0393         assert dag.nodes[2].node_id == "stage3"
0394         assert dag.nodes[2].depends_on == ["stage2"]  # 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         # Simulate job outputs in XCom with correct key format (job_id:key)
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         # Create a simple workflow
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}),  # 2 parallel jobs
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         # Execute workflow
0462         objectives = executor.execute(design_point)
0463         
0464         # Verify execution
0465         assert objectives == {}  # No objectives defined
0466         
0467         # Should have executed 2 jobs (from job_factory n=2)
0468         assert len(executor.global_xcom) >= 2
0469         
0470         # Check output directories exists
0471         assert executor.output_dir.exists()
0472         assert (executor.output_dir / "evaluate").exists()
0473         assert any((executor.output_dir / "evaluate").iterdir())
0474 
0475         # Confirm that 2 job directories were created
0476         job_dirs = list((executor.output_dir / "evaluate").glob("eval_job_*"))
0477         assert len(job_dirs) == 2
0478 
0479 
0480 # Pytest fixtures
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)