Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 08:24:57

0001 """DAG Executor for orchestrating workflow execution with evaluators.
0002 
0003 The DAG Executor is the central orchestration engine that executes workflows
0004 using the evaluator framework. It handles:
0005 - Topological sorting of stages based on dependencies
0006 - Context hierarchy (Branch → Stage → Job)
0007 - Evaluator selection and execution
0008 - XCom data passing between jobs
0009 - Checkpoint logging and artifact collection
0010 - Objective computation from outputs
0011 
0012 Key workflow:
0013     1. Build DAG from workflow definition
0014     2. Topologically sort stages
0015     3. For each layer (stages that can run in parallel):
0016        - Create StageContext with parameters
0017        - Expand jobs via job_factory
0018        - For each job:
0019          - Create JobContext with design point
0020          - Select appropriate evaluator (Bash, Python, Container)
0021          - Execute evaluator.execute(context)
0022          - Log checkpoint
0023     4. Compute objectives from collected outputs
0024     5. Return objectives to optimizer
0025 
0026 Project: AID2E v0.0.0 - AI assisted Detector Design for EIC
0027 Homepage: https://aid2e.github.io/aid2e-framework
0028 Repository: https://github.com/aid2e/AID2E-framework.git
0029 """
0030 
0031 from typing import Dict, Any, List, Optional, Tuple
0032 from pathlib import Path
0033 import json
0034 import logging
0035 import sys
0036 from datetime import datetime
0037 
0038 from aid2e.utilities.configurations.problem_config import (
0039     ProblemConfiguration,
0040 )
0041 from aid2e.utilities.configurations.workflow_config import (
0042     WorkflowDefinition,
0043     BranchDefinition,
0044     StageDefinition,
0045     JobDefinition,
0046     JobFactory,
0047 )
0048 from .dag_types import (
0049     DagDefinition,
0050     DagNode,
0051     DagNodeType,
0052     topological_sort,
0053 )
0054 from .execution_engine import (
0055     BaseExecutionEngine,
0056     BashExecutionEngine,
0057     PythonExecutionEngine,
0058     ContainerExecutionEngine,
0059     StackExecutionEngine,
0060     JobContext,
0061     StageContext,
0062     BranchContext,
0063     WorkflowSharedContext,
0064 )
0065 from .execution_logger import ExecutionLogger
0066 
0067 from aid2e.utilities.configurations.stack_registry import StackRegistry
0068 from .rule_resolution import resolve_job_rule, resolve_payload_templates
0069 
0070 
0071 class DAGExecutor:
0072     """Executor for DAG-based workflow orchestration.
0073     
0074     Orchestrates workflow execution by:
0075     - Building DAG from workflow definition
0076     - Topologically sorting stages for correct execution order
0077     - Creating hierarchical contexts (Branch → Stage → Job)
0078     - Selecting and executing appropriate evaluators
0079     - Managing XCom data flow between jobs
0080     - Logging execution checkpoints
0081     - Computing objectives from outputs
0082     
0083     Attributes:
0084         workflow: Workflow definition to execute.
0085         base_output_dir: Base directory for all execution outputs.
0086         logger: Execution logger for checkpoints and logs.
0087         global_xcom: Shared XCom storage across all jobs.
0088         problem_config: Problem configuration for accessing stack-
0089                         dependent design space and environment
0090                         configuration (optional)
0091         
0092     Example:
0093         >>> workflow = WorkflowDefinition(name="dtlz2_eval", ...)
0094         >>> executor = DAGExecutor(workflow, output_dir="/tmp/runs")
0095         >>> design_point = {"x1": 0.5, "x2": 0.7}
0096         >>> objectives = executor.execute(design_point)
0097         >>> print(objectives)  # {"f1": 0.234, "f2": 0.876}
0098     """
0099     
0100     def __init__(
0101         self,
0102         workflow: WorkflowDefinition,
0103         base_output_dir: str = "/tmp/aid2e_runs",
0104         log_level: str = "INFO",
0105         problem_config: Optional[ProblemConfiguration] = None,
0106         scheduler_config: Optional[Dict[str, Any]] = None,
0107         scheduler_config_resolver=None,
0108     ):
0109         """Initialize DAG Executor.
0110         
0111         Args:
0112             workflow: Workflow definition to execute.
0113             base_output_dir: Base directory for execution outputs.
0114             log_level: Logging level (DEBUG, INFO, WARNING, ERROR).
0115             scheduler_config: Optional scheduler configuration dict with keys:
0116                 - runner_type: str (e.g., "JobLibRunner", "PanDAiDDSRunner")
0117                 - config: scheduler-specific config object or dict
0118         """
0119         self.workflow = workflow
0120         self.base_output_dir = Path(base_output_dir)
0121         self.log_level = log_level
0122         self.problem_config = problem_config
0123         self.scheduler_config = scheduler_config or {}
0124         self.scheduler_config_resolver = scheduler_config_resolver
0125         
0126         timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
0127         if self.problem_config is not None:
0128             work_root = Path(self.problem_config.work_location) / workflow.name / timestamp
0129             output_root = Path(self.problem_config.output_location) / workflow.name / timestamp
0130         else:
0131             output_root = self.base_output_dir / workflow.name / timestamp
0132             work_root = output_root
0133 
0134         work_root.mkdir(parents=True, exist_ok=True)
0135         output_root.mkdir(parents=True, exist_ok=True)
0136         self.work_dir = work_root
0137         self.output_dir = output_root
0138         self.scheduler_submit_dir = self.work_dir / "_scheduler"
0139         self.scheduler_submit_dir.mkdir(parents=True, exist_ok=True)
0140         
0141         # If provided, activate environment variables
0142         if self.problem_config is not None:
0143             if self.problem_config.environment_config is not None:
0144                 self.problem_config.environment_config.activate()
0145 
0146         # Initialize logger
0147         self.logger = ExecutionLogger(
0148             job_name=f"executor_{workflow.name}",
0149             output_dir=str(self.output_dir),
0150             log_level=log_level,
0151         )
0152         
0153         # Global XCom storage: {job_id: {key: value}}
0154         self.global_xcom: Dict[str, Dict[str, Any]] = {}
0155         
0156         # Initialize scheduler if configured
0157         self.scheduler = None
0158         if self.scheduler_config:
0159             self.scheduler = self._create_scheduler()
0160         
0161         self.logger.log_info(f"Initialized DAGExecutor for workflow: {workflow.name}")
0162         self.logger.log_info(f"Output directory: {self.output_dir}")
0163         self.logger.log_info(f"Work directory: {self.work_dir}")
0164         if self.scheduler:
0165             runner_type = self.scheduler_config.get("runner_type", "unknown")
0166             self.logger.log_info(f"Scheduler: {runner_type}")
0167     
0168     def _create_scheduler(self, scheduler_config: Optional[Dict[str, Any]] = None):
0169         """Create and initialize the scheduler from configuration.
0170         
0171         Returns:
0172             Initialized scheduler instance (BaseScheduler subclass).
0173             
0174         Raises:
0175             ValueError: If scheduler_config is invalid or scheduler type not found.
0176         """
0177         scheduler_config = scheduler_config or self.scheduler_config
0178         runner_type = scheduler_config.get("runner_type")
0179         if not runner_type:
0180             raise ValueError("scheduler_config must specify 'runner_type'")
0181         
0182         config = scheduler_config.get("config")
0183         
0184         # Import scheduler dynamically based on runner_type
0185         try:
0186             if runner_type == "JobLibRunner":
0187                 from aid2e.schedulers.JobLib.runner import JobLibScheduler
0188                 return JobLibScheduler(config=config)
0189             elif runner_type == "PanDAiDDSRunner":
0190                 from aid2e.schedulers.PanDAiDDS.runner import PanDAiDDSScheduler
0191                 return PanDAiDDSScheduler(config=config)
0192             elif runner_type == "SlurmRunner":
0193                 from aid2e.schedulers.Slurm.runner import SlurmScheduler
0194                 return SlurmScheduler(config=config)
0195             else:
0196                 raise ValueError(f"Unknown scheduler runner_type: {runner_type}")
0197         except ImportError as e:
0198             self.logger.log_error(
0199                 f"Failed to import scheduler {runner_type}: {e}. "
0200                 f"Make sure the scheduler module is installed."
0201             )
0202             raise ValueError(f"Scheduler {runner_type} not available: {e}") from e
0203         
0204     def execute(self, design_point: Dict[str, Any]) -> Dict[str, float]:
0205         """Execute workflow for a given design point.
0206         
0207         This is the main entry point for workflow execution. It orchestrates
0208         the entire workflow from start to finish and returns computed objectives.
0209 
0210         Args:
0211             design_point: Design point parameters (e.g., {"x1": 0.5, "x2": 0.7}).
0212             
0213         Returns:
0214             objectives: Computed objectives as {objective_name: value}.
0215             
0216         Raises:
0217             ValueError: If workflow is invalid or execution fails.
0218             
0219         Example:
0220             >>> design_point = {"x1": 0.5, "x2": 0.7, "x3": 0.3}
0221             >>> objectives = executor.execute(design_point)
0222             >>> print(objectives)  # {"f1": 0.234, "f2": 0.876}
0223         """
0224 
0225         self.logger.checkpoint(
0226             "workflow_start",
0227             "start",
0228             f"Starting workflow execution for design point: {design_point}",
0229             context={"design_point": design_point},
0230         )
0231 
0232         self.workflow_context = WorkflowSharedContext(
0233             workflow_id=self.workflow.name,
0234             parameters={},
0235         )
0236 
0237         try:
0238             if (
0239                 self.problem_config is not None
0240                 and self.problem_config.design_config is not None
0241                 and self.workflow.stack_type is not None
0242             ):
0243                 stack_class = StackRegistry.get_experimental_stack(self.workflow.stack_type)
0244                 stack = stack_class()
0245                 prepared_geometry_dir = stack.prepare_workflow_geometry(
0246                     workflow_dir=str(self.output_dir),
0247                     design_point=design_point,
0248                     problem_config=self.problem_config,
0249                     workflow_id=self.workflow.name,
0250                 )
0251                 self.workflow_context.parameters["prepared_geometry_dir"] = prepared_geometry_dir
0252                 self.logger.log_info(f"Prepared geometry once at: {prepared_geometry_dir}")
0253 
0254             for branch in self._get_branches():
0255                 self._execute_branch(branch, design_point)
0256 
0257             objectives = self._compute_objectives()
0258 
0259             self.logger.checkpoint(
0260                 "workflow_complete",
0261                 "success",
0262                 f"Workflow execution completed. Objectives: {objectives}",
0263                 context={"objectives": objectives},
0264             )
0265 
0266             return objectives
0267             
0268         except Exception as e:
0269             self.logger.checkpoint(
0270                 "workflow_error",
0271                 "error",
0272                 f"Workflow execution failed: {str(e)}",
0273                 context={"error": str(e)},
0274             )
0275             raise
0276     
0277     def _get_branches(self) -> List[BranchDefinition]:
0278         """Get branches from workflow (create implicit branch if none defined).
0279         
0280         Returns:
0281             List of branches to execute.
0282         """
0283         if self.workflow.branches:
0284             return self.workflow.branches
0285         else:
0286             # Create implicit branch from workflow stages (if any)
0287             # For now, return empty list if no branches defined
0288             # This can be extended to support legacy workflows with direct stages
0289             return []
0290     
0291     def _execute_branch(self, branch: BranchDefinition, design_point: Dict[str, Any]) -> None:
0292         """Execute a single branch.
0293         
0294         Args:
0295             branch: Branch definition to execute.
0296             design_point: Design point parameters.
0297         """
0298         self.logger.checkpoint(
0299             "branch_start",
0300             "start",
0301             f"Starting branch: {branch.name}",
0302             context={"branch_name": branch.name},
0303         )
0304         
0305         # Create branch context
0306         branch_context = BranchContext(
0307             branch_id=branch.name,
0308             parameters={},  # Can be extended to include branch-level params
0309         )
0310         
0311         # Build DAG from stages
0312         dag = self._build_dag_from_stages(branch.stages, branch.name)
0313         
0314         # Topologically sort stages
0315         try:
0316             topo_order = topological_sort(dag)
0317         except ValueError as e:
0318             self.logger.checkpoint(
0319                 "branch_error",
0320                 "error",
0321                 f"Failed to sort branch DAG: {str(e)}",
0322                 context={"branch_name": branch.name, "error": str(e)},
0323             )
0324             raise
0325         
0326         self.logger.log_info(
0327             f"Branch {branch.name} execution order: {topo_order.sorted_node_ids}"
0328         )
0329         
0330         # Execute stages layer by layer
0331         for layer_idx, layer in enumerate(topo_order.layers):
0332             self.logger.log_info(
0333                 f"Executing layer {layer_idx} with {len(layer)} stages: "
0334                 f"{[node.node_id for node in layer]}"
0335             )
0336             
0337             # Execute all stages in this layer (can be parallelized)
0338             for node in layer:
0339                 stage = self._get_stage_by_name(branch.stages, node.node_id)
0340                 if stage:
0341                     self._execute_stage(stage, branch, branch_context, design_point)
0342         
0343         self.logger.checkpoint(
0344             "branch_complete",
0345             "success",
0346             f"Branch {branch.name} completed",
0347             context={"branch_name": branch.name},
0348         )
0349     
0350     def _build_dag_from_stages(
0351         self, stages: List[StageDefinition], branch_name: str
0352     ) -> DagDefinition:
0353         """Build DAG from stage list.
0354         
0355         For now, assumes simple sequential execution (each stage depends on previous).
0356         Can be extended to support explicit dependencies from stage definitions.
0357         
0358         Args:
0359             stages: List of stages in the branch.
0360             branch_name: Name of the parent branch.
0361             
0362         Returns:
0363             DagDefinition with nodes and edges.
0364         """
0365         nodes = []
0366         for idx, stage in enumerate(stages):
0367             depends_on = [stages[idx - 1].name] if idx > 0 else []
0368             node = DagNode(
0369                 node_id=stage.name,
0370                 node_type=DagNodeType.STAGE,
0371                 depends_on=depends_on,
0372                 description=f"Stage in branch {branch_name}",
0373             )
0374             nodes.append(node)
0375         
0376         return DagDefinition(
0377             name=f"dag_{branch_name}",
0378             nodes=nodes,
0379         )
0380     
0381     def _get_stage_by_name(
0382         self, stages: List[StageDefinition], stage_name: str
0383     ) -> Optional[StageDefinition]:
0384         """Find stage by name in stage list.
0385         
0386         Args:
0387             stages: List of stages to search.
0388             stage_name: Name of the stage to find.
0389             
0390         Returns:
0391             StageDefinition if found, None otherwise.
0392         """
0393         for stage in stages:
0394             if stage.name == stage_name:
0395                 return stage
0396         return None
0397     
0398     def _execute_stage(
0399         self,
0400         stage: StageDefinition,
0401         branch: BranchDefinition,
0402         branch_context: BranchContext,
0403         design_point: Dict[str, Any],
0404     ) -> None:
0405         """Execute a single stage.
0406         
0407         Args:
0408             stage: Stage definition to execute.
0409             branch_context: Parent branch context.
0410             design_point: Design point parameters.
0411         """
0412         self.logger.checkpoint(
0413             "stage_start",
0414             "start",
0415             f"Starting stage: {stage.name}",
0416             context={"stage_name": stage.name},
0417         )
0418         
0419         # Create stage context
0420         stage_context = StageContext(
0421             stage_id=stage.name,
0422             parameters=stage.parallelism.model_dump(),  # Include parallelism params
0423             branch_context=branch_context,
0424         )
0425         
0426         # Expand jobs from job_factory (if provided)
0427         jobs = self._expand_jobs(stage)
0428         
0429         self.logger.log_info(f"Stage {stage.name} has {len(jobs)} jobs to execute")
0430         
0431         if self.scheduler_config_resolver is not None:
0432             scheduler_config = self.scheduler_config_resolver(branch, stage)
0433         else:
0434             scheduler_config = self.scheduler_config or None
0435 
0436         # Use scheduler to execute stage if configured,
0437         # otherwise execute directly
0438         if scheduler_config:
0439             self._execute_stage_with_scheduler(
0440                 stage, jobs, stage_context, design_point, scheduler_config
0441             )
0442         else:
0443             jobs_seen = []
0444             for job in jobs:
0445                 job_id = job.name
0446                 n_seen = jobs_seen.count(job_id)
0447                 jobs_seen.append(job_id)
0448                 if n_seen > 0:
0449                     job_id = job_id + f"_{n_seen - 1}"
0450                     job.name = job_id
0451                 task_id = f"{stage.name}:{job_id}"
0452                 self._execute_job(job, job_id, task_id, stage_context, design_point)
0453         
0454         self.logger.checkpoint(
0455             "stage_complete",
0456             "success",
0457             f"Stage {stage.name} completed with {len(jobs)} jobs",
0458             context={"stage_name": stage.name, "num_jobs": len(jobs)},
0459         )
0460 
0461     def _execute_stage_with_scheduler(
0462         self,
0463         stage: StageDefinition,
0464         jobs: List[JobDefinition],
0465         stage_context: StageContext,
0466         design_point: Dict[str, Any],
0467         scheduler_config: Dict[str, Any],
0468     ) -> None:
0469         """Execute a stage using the configured scheduler.
0470         
0471         Converts job definitions to scheduler format and handles result collection.
0472         
0473         Args:
0474             stage: Stage definition.
0475             jobs: Expanded list of jobs to execute.
0476             stage_context: Parent stage context.
0477             design_point: Design point parameters.
0478         """
0479         self.logger.log_info(f"Executing stage {stage.name} with scheduler")
0480         if scheduler_config == self.scheduler_config and self.scheduler is not None:
0481             scheduler = self.scheduler
0482         else:
0483             scheduler = self._create_scheduler(scheduler_config)
0484 
0485         # Convert jobs to scheduler format
0486         job_definitions = []
0487         jobs_seen = []
0488         for job in jobs:
0489             job_id = job.name
0490             n_seen = jobs_seen.count(job_id)
0491             jobs_seen.append(job_id)
0492             if n_seen > 0:
0493                 job_id = job_id + f"_{n_seen - 1}"
0494                 job.name = job_id
0495             task_id = f"{stage.name}:{job_id}"
0496             execution_dir, output_dir = self._build_job_directories(stage.name, job_id)
0497             
0498             # Create job context for this job
0499             job_context = JobContext(
0500                 task_id=task_id,
0501                 job_id=job_id,
0502                 stage_id=stage_context.stage_id,
0503                 workflow_id=self.workflow.name,
0504                 design_point=design_point,
0505                 xcom=self.global_xcom,
0506                 stage_context=stage_context,
0507                 execution_dir=str(execution_dir),
0508                 output_dir=str(output_dir),
0509                 workflow_context=self.workflow_context,
0510             )
0511             
0512             # Convert to scheduler job definition format
0513             scheduler_job = self._convert_job_to_scheduler_format(
0514                 job, job_id, job_context, scheduler_config
0515             )
0516             job_definitions.append(scheduler_job)
0517         
0518         # Prepare parallelism policy from stage config
0519         parallelism_policy = {
0520             "max_concurrent": stage.parallelism.max_concurrent,
0521             "retry_max": stage.parallelism.retry_max,
0522             "timeout_sec": stage.parallelism.timeout_sec,
0523             "poll_interval": 5,  # Default poll interval for async schedulers
0524         }
0525         
0526         # Execute stage via scheduler
0527         try:
0528             result = scheduler.run_stage(
0529                 stage_name=stage.name,
0530                 job_definitions=job_definitions,
0531                 parallelism_policy=parallelism_policy,
0532                 working_dir=str(self.scheduler_submit_dir / stage.name),
0533             )
0534             
0535             # Process results and update XCom
0536             self._process_scheduler_results(result, jobs, stage.name)
0537             
0538             if not result.success:
0539                 raise RuntimeError(
0540                     f"Stage {stage.name} failed: {result.error_message}"
0541                 )
0542                 
0543         except Exception as e:
0544             self.logger.checkpoint(
0545                 "stage_scheduler_error",
0546                 "error",
0547                 f"Scheduler execution failed for stage {stage.name}: {str(e)}",
0548                 context={"stage_name": stage.name, "error": str(e)},
0549             )
0550             raise
0551     
0552     def _convert_job_to_scheduler_format(
0553         self,
0554         job: JobDefinition,
0555         job_id: str,
0556         job_context: JobContext,
0557         scheduler_config: Optional[Dict[str, Any]] = None,
0558     ) -> Dict[str, Any]:
0559         """Convert a JobDefinition to scheduler job format.
0560         
0561         Args:
0562             job: Job definition from workflow.
0563             job_id: Unique job identifier.
0564             job_context: Job context with design point and XCom.
0565             
0566         Returns:
0567             Dict in scheduler format with keys: name, command, payload, outputs, etc.
0568         """
0569         runner_type = (scheduler_config or self.scheduler_config).get("runner_type")
0570         evaluator_type = job.payload.get("evaluator_type", "bash")
0571         design_file = self._materialize_design_file(job_id, job_context)
0572         if runner_type == "SlurmRunner" and evaluator_type == "python":
0573             # TODO(slurm-python): support workflow Python evaluator jobs by translating
0574             # them into a Slurm-safe command/file-based execution path.
0575             raise ValueError(
0576                 f"Job {job_id} uses evaluator_type='python', which SlurmScheduler v1 does not support."
0577             )
0578 
0579         command = job.command
0580         outputs = job.outputs or []
0581         if runner_type == "SlurmRunner":
0582             command = self._resolve_scheduler_job_command(
0583                 job,
0584                 job_id,
0585                 job_context,
0586                 design_file=design_file,
0587             )
0588             outputs = self._resolve_scheduler_job_outputs(
0589                 job,
0590                 job_id,
0591                 job_context,
0592                 design_file=design_file,
0593             )
0594 
0595         scheduler_job = {
0596             "job_id": job_id,
0597             "name": job.name,
0598             "command": command,
0599             "payload": {**job.payload},
0600             "outputs": outputs,
0601             "job_context": job_context,  # Pass context for potential XCom access in scheduler
0602         }
0603         
0604         # For Python evaluators, add function reference to payload
0605         if evaluator_type == "python":
0606             python_callable = job.payload.get("python_callable")
0607             if python_callable:
0608                 # For schedulers that support function submission (e.g., PanDAiDDS)
0609                 scheduler_job["function"] = python_callable
0610                 scheduler_job["params"] = {
0611                     "context": job_context,
0612                     **(job.payload.get("op_kwargs", {})),
0613                 }
0614         
0615         # Add design point to payload
0616         scheduler_job["payload"]["design_point"] = job_context.design_point
0617         scheduler_job["payload"]["job_id"] = job_id
0618         scheduler_job["payload"]["execution_dir"] = job_context.execution_dir
0619         scheduler_job["payload"]["output_dir"] = job_context.output_dir
0620         scheduler_job["payload"]["design_file"] = design_file
0621         
0622         # Add resource requirements
0623         if job.resources:
0624             scheduler_job["resources"] = job.resources
0625         
0626         return scheduler_job
0627 
0628     def _resolve_scheduler_job_command(
0629         self,
0630         job: JobDefinition,
0631         job_id: str,
0632         job_context: JobContext,
0633         *,
0634         design_file: str,
0635     ) -> str:
0636         """Resolve command/rule/payload into a final scheduler command."""
0637         rule_context = {
0638             "job_id": job_id,
0639             "output_dir": job_context.output_dir,
0640             "execution_dir": job_context.execution_dir,
0641             "workflow_id": job_context.workflow_id,
0642             "stage_id": job_context.stage_id,
0643             "design_point": job_context.design_point,
0644             "design_file": design_file,
0645             "python_executable": sys.executable,
0646             "repo_root": str(Path(__file__).resolve().parents[4]),
0647             "prepared_geometry_dir": self.workflow_context.parameters.get("prepared_geometry_dir"),
0648             "stage_outputs": {},
0649             "xcom": self.global_xcom,
0650         }
0651         if "{" in job.command and "}" in job.command:
0652             resolved_command = resolve_payload_templates(
0653                 {"command": job.command},
0654                 rule_context,
0655                 logger=self.logger,
0656             )["command"]
0657             job = job.model_copy(update={"command": resolved_command})
0658         return resolve_job_rule(job, rule_context, logger=self.logger)
0659 
0660     def _resolve_scheduler_job_outputs(
0661         self,
0662         job: JobDefinition,
0663         job_id: str,
0664         job_context: JobContext,
0665         *,
0666         design_file: str,
0667     ) -> List[Dict[str, Any]]:
0668         """Resolve scheduler output specs against the same runtime context as rules."""
0669         resolved_payload = resolve_payload_templates(
0670             job.payload,
0671             {
0672                 "job_id": job_id,
0673                 "output_dir": job_context.output_dir,
0674                 "execution_dir": job_context.execution_dir,
0675                 "workflow_id": job_context.workflow_id,
0676                 "stage_id": job_context.stage_id,
0677                 "design_point": job_context.design_point,
0678                 "design_file": design_file,
0679                 "repo_root": str(Path(__file__).resolve().parents[4]),
0680                 "stage_outputs": {},
0681                 "xcom": self.global_xcom,
0682             },
0683             logger=self.logger,
0684         )
0685 
0686         resolved_outputs: List[Dict[str, Any]] = []
0687         for output_spec in job.outputs or []:
0688             payload = {
0689                 "path": getattr(output_spec, "path", None),
0690                 "format": getattr(output_spec, "format", None),
0691             }
0692             resolved_outputs.append(
0693                 resolve_payload_templates(
0694                     payload,
0695                     {
0696                         "job_id": job_id,
0697                         "output_dir": job_context.output_dir,
0698                         "execution_dir": job_context.execution_dir,
0699                         "workflow_id": job_context.workflow_id,
0700                         "stage_id": job_context.stage_id,
0701                         "design_point": job_context.design_point,
0702                         "design_file": design_file,
0703                         "repo_root": str(Path(__file__).resolve().parents[4]),
0704                         "stage_outputs": {},
0705                         "xcom": self.global_xcom,
0706                         "payload": resolved_payload,
0707                     },
0708                     logger=self.logger,
0709                 )
0710             )
0711 
0712         return resolved_outputs
0713 
0714     def _materialize_design_file(
0715         self,
0716         job_id: str,
0717         job_context: JobContext,
0718     ) -> str:
0719         """Persist the incoming design point for command-style scheduler jobs."""
0720         from aid2e.optimizers.base import Trial
0721 
0722         design_path = Path(job_context.execution_dir) / "design_point.json"
0723         trial = Trial(
0724             index=-1,
0725             parameters=dict(job_context.design_point or {}),
0726             metadata={
0727                 "job_id": job_id,
0728                 "workflow_id": job_context.workflow_id,
0729                 "stage_id": job_context.stage_id,
0730             },
0731         )
0732         trial.save_to_json(design_path)
0733         return str(design_path)
0734 
0735     def _build_job_directories(self, stage_id: str, job_id: str) -> Tuple[Path, Path]:
0736         """Create paired work/output directories for one job."""
0737         execution_dir = self.work_dir / stage_id / job_id
0738         output_dir = self.output_dir / stage_id / job_id
0739         execution_dir.mkdir(parents=True, exist_ok=True)
0740         output_dir.mkdir(parents=True, exist_ok=True)
0741         return execution_dir, output_dir
0742     
0743     def _process_scheduler_results(
0744         self,
0745         stage_result,
0746         jobs: List[JobDefinition],
0747         stage_name: str,
0748     ) -> None:
0749         """Process results from scheduler execution and update XCom.
0750         
0751         Args:
0752             stage_result: StageExecutionResult from scheduler.
0753             jobs: Original job definitions.
0754             stage_name: Name of the stage.
0755         """
0756         self.logger.log_info(
0757             f"Processing scheduler results for stage {stage_name}: "
0758             f"{len(stage_result.job_statuses)} jobs"
0759         )
0760         
0761         # Process each job result
0762         for job_status in stage_result.job_statuses:
0763             job_id = job_status.job_id
0764             
0765             # Log job completion
0766             if job_status.status == "completed":
0767                 self.logger.checkpoint(
0768                     "job_complete",
0769                     "success",
0770                     f"Job {job_id} completed via scheduler",
0771                     context={
0772                         "job_id": job_id,
0773                         "return_code": job_status.return_code,
0774                     },
0775                 )
0776             else:
0777                 self.logger.checkpoint(
0778                     "job_error",
0779                     "error",
0780                     f"Job {job_id} failed via scheduler",
0781                     context={
0782                         "job_id": job_id,
0783                         "return_code": job_status.return_code,
0784                         "stderr": job_status.stderr,
0785                     },
0786                 )
0787             
0788             # Store stdout/stderr in XCom if available
0789             if job_status.stdout:
0790                 self.global_xcom[f"{job_id}:stdout"] = job_status.stdout
0791             if job_status.stderr:
0792                 self.global_xcom[f"{job_id}:stderr"] = job_status.stderr
0793             
0794             # Store outputs in XCom (objectives, metrics, etc.)
0795             if job_status.outputs:
0796                 self.logger.log_info(
0797                     f"Storing {len(job_status.outputs)} outputs from job {job_id}"
0798                 )
0799                 for output_key, output_value in job_status.outputs.items():
0800                     xcom_key = f"{job_id}:{output_key}"
0801                     self.global_xcom[xcom_key] = output_value
0802                     self.logger.log_info(f"  XCom: {xcom_key} = {output_value}")
0803         
0804         # Store artifacts from scheduler in XCom
0805         if stage_result.artifacts:
0806             self.logger.log_info(
0807                 f"Storing {len(stage_result.artifacts)} artifacts from stage {stage_name}"
0808             )
0809             for artifact_path, artifact_data in stage_result.artifacts.items():
0810                 # Store artifact under job-specific key
0811                 # Artifacts might come from different jobs in the stage
0812                 xcom_key = f"{stage_name}:artifact:{artifact_path}"
0813                 self.global_xcom[xcom_key] = artifact_data
0814     
0815     def _expand_jobs(self, stage: StageDefinition) -> List[JobDefinition]:
0816         """Expand jobs from job_factory.
0817         
0818         Args:
0819             stage: Stage definition with jobs and optional job_factory.
0820             
0821         Returns:
0822             List of expanded job definitions.
0823         """
0824         if not stage.jobs:
0825             return []
0826         
0827         if not stage.job_factory:
0828             # No factory, return jobs as-is
0829             return stage.jobs
0830         
0831         # Expand first job using factory
0832         template_job = stage.jobs[0]
0833         factory = stage.job_factory
0834         
0835         if factory.type == "range":
0836             # Create N copies of the template job
0837             n = factory.params.get("n", 1)
0838             expanded = []
0839             for i in range(n):
0840                 job_copy = JobDefinition(
0841                     name=f"{template_job.name}_{i}",
0842                     command=template_job.command,
0843                     rule=template_job.rule,
0844                     payload={**template_job.payload, "job_index": i},
0845                     resources=template_job.resources,
0846                     outputs=template_job.outputs,
0847                 )
0848                 expanded.append(job_copy)
0849             return expanded
0850         else:
0851             # Unsupported factory type, return original jobs
0852             self.logger.log_warning(
0853                 f"Unsupported job_factory type: {factory.type}. Using original jobs."
0854             )
0855             return stage.jobs
0856     
0857     def _execute_job(
0858         self,
0859         job: JobDefinition,
0860         job_id: str,
0861         task_id: str,
0862         stage_context: StageContext,
0863         design_point: Dict[str, Any],
0864     ) -> None:
0865         """Execute a single job using the appropriate evaluator.
0866         
0867         Args:
0868             job: Job definition to execute.
0869             job_id: Unique job identifier.
0870             task_id: Key encoding stage, job ID
0871             stage_context: Parent stage context.
0872             design_point: Design point parameters.
0873         """
0874         self.logger.checkpoint(
0875             "job_start",
0876             "start",
0877             f"Starting job: {job_id}",
0878             context={"job_id": job_id, "job_name": job.name},
0879         )
0880 
0881         execution_dir, output_dir = self._build_job_directories(stage_context.stage_id, job_id)
0882         
0883         # Create job context
0884         job_context = JobContext(
0885             task_id=task_id,
0886             job_id=job_id,
0887             stage_id=stage_context.stage_id,
0888             workflow_id=self.workflow.name,
0889             design_point=design_point,
0890             xcom=self.global_xcom,
0891             stage_context=stage_context,
0892             execution_dir=str(execution_dir),
0893             output_dir=str(output_dir),
0894             problem_config=self.problem_config,
0895             workflow_context=self.workflow_context,
0896         )
0897         
0898         # Select and create evaluator
0899         evaluator = self._create_evaluator(job, job_id)
0900         
0901         try:
0902             # Execute evaluator
0903             result = evaluator.execute(job_context)
0904             
0905             # Store result in XCom
0906             if result is not None:
0907                 job_context.xcom_push("result", result)
0908             
0909             self.logger.checkpoint(
0910                 "job_complete",
0911                 "success",
0912                 f"Job {job_id} completed successfully",
0913                 context={
0914                     "job_id": job_id,
0915                     "result": str(result)[:200] if result else None,
0916                 },
0917             )
0918             
0919         except Exception as e:
0920             self.logger.checkpoint(
0921                 "job_error",
0922                 "error",
0923                 f"Job {job_id} failed: {str(e)}",
0924                 context={"job_id": job_id, "error": str(e)},
0925             )
0926             
0927             # Check if retry is needed
0928             max_retries = stage_context.parameters.get("retry_max", 0)
0929             if max_retries > 0:
0930                 self.logger.log_warning(
0931                     f"Job {job_id} failed. Retries not yet implemented."
0932                 )
0933             
0934             raise
0935     
0936     def _create_evaluator(self, job: JobDefinition, job_id: str) -> BaseExecutionEngine:
0937         """Create appropriate execution engine for a job.
0938         
0939         Selects execution engine type based on job definition (command, payload, etc).
0940         
0941         Args:
0942             job: Job definition.
0943             job_id: Unique job identifier.
0944             
0945         Returns:
0946             BaseExecutionEngine instance (BashExecutionEngine, PythonExecutionEngine, or ContainerExecutionEngine).
0947         """
0948         # Check for explicit evaluator_type in payload
0949         evaluator_type = job.payload.get("evaluator_type", "bash")
0950         
0951         if evaluator_type == "container":
0952             # ContainerExecutionEngine
0953             return ContainerExecutionEngine(
0954                 engine_id=job_id,
0955                 image=job.payload.get("image", "python:3.9"),
0956                 command=job.payload.get("container_command", ["/bin/bash", "-c", job.command]),
0957                 environment=job.payload.get("environment", {}),
0958                 volumes=job.payload.get("volumes", {}),
0959                 resources=job.resources,
0960             )
0961         elif evaluator_type == "python":
0962             # PythonExecutionEngine (requires python_callable in payload)
0963             python_callable = job.payload.get("python_callable")
0964             if not python_callable:
0965                 raise ValueError(
0966                     f"Job {job_id} specifies evaluator_type='python' but missing 'python_callable'"
0967                 )
0968             return PythonExecutionEngine(
0969                 engine_id=job_id,
0970                 python_callable=python_callable,
0971                 op_args=job.payload.get("op_args", ()),
0972                 op_kwargs=job.payload.get("op_kwargs", {}),
0973             )
0974         elif evaluator_type == "stack":
0975             # StackExecutionEngine (requires stack_type in payload and layers in job)
0976             stack_type = job.payload.get("stack_type")
0977             if not stack_type:
0978                 raise ValueError(
0979                     f"Job {job_id} specifies evaluator_type='stack' but is missing 'stack_type'"
0980                 )
0981             layers = getattr(job, 'layers', [])
0982             if not layers:
0983                 raise RuntimeError(
0984                     f"Job {job_id} specifies evaluator_type='stack' but is missing the layer configurations"
0985                 )
0986             return StackExecutionEngine(
0987                 engine_id=job_id,
0988                 stack_type=stack_type,
0989                 layers=layers,
0990             )
0991         else:
0992             # Default to BashExecutionEngine
0993             return BashExecutionEngine(
0994                 engine_id=job_id,
0995                 bash_command=job.command,
0996                 env=job.payload.get("env", {}),
0997             )
0998     
0999     def _compute_objectives(self) -> Dict[str, float]:
1000         """Compute objectives from workflow outputs.
1001         
1002         For now, returns a placeholder. This should be extended to:
1003         1. Read output artifacts from jobs
1004         2. Apply objective computation plans
1005         3. Return final objective values
1006         
1007         Returns:
1008             objectives: {objective_name: value}.
1009         """
1010         self.logger.checkpoint(
1011             "objectives_compute",
1012             "start",
1013             "Computing objectives from outputs",
1014             context={},
1015         )
1016         
1017         # Placeholder implementation
1018         # TODO: Implement objective computation from:
1019         # - self.workflow.objectives
1020         # - self.workflow.combined_objectives
1021         # - Job outputs stored in XCom or artifacts
1022         
1023         objectives = {}
1024         
1025         # Extract from XCom
1026         # XCom keys are in format "job_id:key"
1027         for xcom_key, value in self.global_xcom.items():
1028             # Check for objectives dict (from single branch case)
1029             if xcom_key.endswith(":objectives") and isinstance(value, dict):
1030                 objectives.update(value)
1031             
1032             # Check for individual objective values (from separate branches case)
1033             for obj_def in self.workflow.objectives:
1034                 if xcom_key.endswith(f":{obj_def.name}"):
1035                     objectives[obj_def.name] = value
1036         
1037         self.logger.checkpoint(
1038             "objectives_computed",
1039             "success",
1040             f"Objectives computed: {objectives}",
1041             context={"objectives": objectives},
1042         )
1043         
1044         return objectives
1045 
1046 
1047 def create_executor_from_config(
1048     workflow_config_path: str,
1049     output_dir: str = "/tmp/aid2e_runs",
1050 ) -> DAGExecutor:
1051     """Create DAGExecutor from workflow configuration file.
1052     
1053     Convenience function for loading workflow from YAML/JSON config.
1054     
1055     Args:
1056         workflow_config_path: Path to workflow configuration file.
1057         output_dir: Base directory for execution outputs.
1058         
1059     Returns:
1060         DAGExecutor instance.
1061         
1062     Example:
1063         >>> executor = create_executor_from_config("configs/dtlz2.yml")
1064         >>> objectives = executor.execute({"x1": 0.5, "x2": 0.7})
1065     """
1066     from aid2e.utilities.configurations import load_config
1067     from aid2e.utilities.runtime_builders import build_workflow_executor_from_config
1068 
1069     config = load_config(workflow_config_path)
1070     return build_workflow_executor_from_config(
1071         config.workflows,
1072         problem_cfg=config.problem,
1073         scheduler_cfg=config.scheduler,
1074         base_output_dir=output_dir,
1075     )