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
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
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
0154 self.global_xcom: Dict[str, Dict[str, Any]] = {}
0155
0156
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
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
0287
0288
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
0306 branch_context = BranchContext(
0307 branch_id=branch.name,
0308 parameters={},
0309 )
0310
0311
0312 dag = self._build_dag_from_stages(branch.stages, branch.name)
0313
0314
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
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
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
0420 stage_context = StageContext(
0421 stage_id=stage.name,
0422 parameters=stage.parallelism.model_dump(),
0423 branch_context=branch_context,
0424 )
0425
0426
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
0437
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
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
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
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
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,
0524 }
0525
0526
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
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
0574
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,
0602 }
0603
0604
0605 if evaluator_type == "python":
0606 python_callable = job.payload.get("python_callable")
0607 if python_callable:
0608
0609 scheduler_job["function"] = python_callable
0610 scheduler_job["params"] = {
0611 "context": job_context,
0612 **(job.payload.get("op_kwargs", {})),
0613 }
0614
0615
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
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
0762 for job_status in stage_result.job_statuses:
0763 job_id = job_status.job_id
0764
0765
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
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
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
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
0811
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
0829 return stage.jobs
0830
0831
0832 template_job = stage.jobs[0]
0833 factory = stage.job_factory
0834
0835 if factory.type == "range":
0836
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
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
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
0899 evaluator = self._create_evaluator(job, job_id)
0900
0901 try:
0902
0903 result = evaluator.execute(job_context)
0904
0905
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
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
0949 evaluator_type = job.payload.get("evaluator_type", "bash")
0950
0951 if evaluator_type == "container":
0952
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
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
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
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
1018
1019
1020
1021
1022
1023 objectives = {}
1024
1025
1026
1027 for xcom_key, value in self.global_xcom.items():
1028
1029 if xcom_key.endswith(":objectives") and isinstance(value, dict):
1030 objectives.update(value)
1031
1032
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 )