File indexing completed on 2026-08-12 08:24:57
0001 """Rule resolution and payload validation for job execution.
0002
0003 Implements template-based command construction and payload
0004 validation for conditional job execution.
0005
0006 Project: AID2E v0.0.0 - AI assisted Detector Design for EIC
0007 Homepage: https://aid2e.github.io/aid2e-framework
0008 Repository: https://github.com/aid2e/AID2E-framework.git
0009 """
0010
0011 import re
0012 from typing import Any, Dict, Optional, Tuple
0013 from aid2e.utilities.configurations.workflow_config import JobDefinition
0014 from aid2e.utilities.workflows.execution_logger import ExecutionLogger
0015
0016
0017 class RuleResolutionError(Exception):
0018 """Raised when rule resolution fails."""
0019 pass
0020
0021
0022 class PayloadValidationError(Exception):
0023 """Raised when payload validation fails."""
0024 pass
0025
0026
0027 def resolve_payload_templates(
0028 payload: Dict[str, Any],
0029 context: Dict[str, Any],
0030 logger: Optional[ExecutionLogger] = None
0031 ) -> Dict[str, Any]:
0032 """Resolve template variables in payload dict.
0033
0034 Recursively processes payload dict, replacing template variables with
0035 context values. Supports nested dicts and lists.
0036
0037 Template Variables:
0038 - {{job_id}}: Job name
0039 - {{output_dir}}: Job output directory
0040 - {{stage_outputs[stage_name]}}: Output path from previous stage
0041 - {{input_design_params}}: Design parameters file path
0042
0043 Args:
0044 payload: Payload dict potentially containing template variables
0045 context: Runtime context dict
0046 logger: Optional execution logger for logging operations
0047
0048 Returns:
0049 Dict with all templates resolved
0050
0051 Raises:
0052 RuleResolutionError: If template variable is undefined in context
0053
0054 Example:
0055 >>> payload = {
0056 ... "input_file": "{{input_design_params}}",
0057 ... "output_dir": "{{output_dir}}",
0058 ... "job_id": "{{job_id}}"
0059 ... }
0060 >>> context = {
0061 ... "input_design_params": "/path/to/design.params",
0062 ... "output_dir": "/tmp/stage_output",
0063 ... "job_id": 0
0064 ... }
0065 >>> resolved = resolve_payload_templates(payload, context)
0066 # resolved = {"input_file": "/path/to/design.params", ...}
0067 """
0068 if logger:
0069 logger.checkpoint(
0070 stage="payload_template_resolution",
0071 status="start",
0072 message="Resolving payload template variables",
0073 context={"payload_keys": list(payload.keys())}
0074 )
0075
0076 resolved = {}
0077
0078 for key, value in payload.items():
0079 if isinstance(value, str):
0080
0081 resolved[key] = _resolve_template_string(value, context, logger)
0082 elif isinstance(value, dict):
0083
0084 resolved[key] = resolve_payload_templates(value, context, logger)
0085 elif isinstance(value, list):
0086
0087 resolved[key] = [
0088 _resolve_template_string(item, context, logger) if isinstance(item, str) else item
0089 for item in value
0090 ]
0091 else:
0092
0093 resolved[key] = value
0094
0095 if logger:
0096 logger.checkpoint(
0097 stage="payload_template_resolution",
0098 status="success",
0099 message="Payload templates resolved successfully",
0100 context={
0101 "resolved_keys": list(resolved.keys()),
0102 "sample_values": {k: str(v)[:50] for k, v in list(resolved.items())[:3]}
0103 }
0104 )
0105
0106 return resolved
0107
0108
0109 def _resolve_template_string(
0110 template: str,
0111 context: Dict[str, Any],
0112 logger: Optional[ExecutionLogger] = None
0113 ) -> str:
0114 """Resolve a single template string.
0115
0116 Args:
0117 template: Template string (e.g., "{{input_design_params}}")
0118 context: Runtime context
0119 logger: Optional logger
0120
0121 Returns:
0122 Resolved string
0123
0124 Raises:
0125 RuleResolutionError: If variable is undefined
0126 """
0127
0128 pattern = r'\{\{([^}]+)\}\}'
0129 variables = re.findall(pattern, template)
0130
0131 result = template
0132 for var in variables:
0133
0134 target = "{{" + f"{var}" + "}}"
0135 value = _get_context_value(var, context)
0136 if value is None:
0137 raise RuleResolutionError(f"Undefined template variable: {target}")
0138
0139 result = result.replace(target, str(value))
0140
0141 return result
0142
0143
0144 def _get_context_value(var_path: str, context: Dict[str, Any]) -> Any:
0145 """Get value from context using path notation.
0146
0147 Supports:
0148 - Simple keys: "job_id"
0149 - Dict access: "stage_outputs[preparation]"
0150 - Nested access: "payload[metadata][version]"
0151
0152 Args:
0153 var_path: Variable path string
0154 context: Context dict
0155
0156 Returns:
0157 Value from context, or None if not found
0158 """
0159
0160 if "[" in var_path and "]" in var_path:
0161
0162 match = re.match(r'(\w+)\[([^\]]+)\]', var_path)
0163 if not match:
0164 return None
0165
0166 base_name = match.group(1)
0167 key_path = match.group(2)
0168
0169 if base_name not in context:
0170 return None
0171
0172 obj = context[base_name]
0173
0174
0175 for key in key_path.split("]["):
0176 key = key.strip("]").strip("[")
0177 if isinstance(obj, dict):
0178 obj = obj.get(key)
0179 else:
0180 return None
0181
0182 if obj is None:
0183 return None
0184
0185 return obj
0186 else:
0187
0188 return context.get(var_path)
0189
0190
0191 def resolve_job_rule(
0192 job: JobDefinition,
0193 context: Dict[str, Any],
0194 logger: Optional[ExecutionLogger] = None
0195 ) -> str:
0196 """Resolve job rule template to final command.
0197
0198 Implements experimental_stack.py StackLayer pattern for command construction.
0199
0200 Rule Template Variables:
0201 - {{command}}: Job command
0202 - {{payload[key]}}: Value from resolved payload
0203 - {{job_id}}, {{output_dir}}, etc.: Context variables
0204
0205 Args:
0206 job: JobDefinition with command and optional rule
0207 context: Runtime context with template variables
0208 logger: Optional execution logger
0209
0210 Returns:
0211 Final command string ready for execution
0212
0213 Raises:
0214 RuleResolutionError: If rule cannot be resolved
0215
0216 Example:
0217 >>> job = JobDefinition(
0218 ... name="test",
0219 ... command="python compute.py",
0220 ... rule="{{command}} {{payload[input]}} {{payload[output]}}",
0221 ... payload={"input": "{{input_design_params}}", "output": "{{output_dir}}"}
0222 ... )
0223 >>> context = {
0224 ... "input_design_params": "/data/design.params",
0225 ... "output_dir": "/tmp/out",
0226 ... "job_id": 0
0227 ... }
0228 >>> cmd = resolve_job_rule(job, context)
0229 # cmd = "python compute.py /data/design.params /tmp/out"
0230 """
0231 if logger:
0232 logger.checkpoint(
0233 stage="rule_resolution",
0234 status="start",
0235 message="Resolving job rule template",
0236 context={"job_name": job.name, "rule": job.rule}
0237 )
0238
0239
0240 resolved_payload = resolve_payload_templates(job.payload, context, logger)
0241
0242
0243 rule = job.rule or "{{command}}"
0244
0245 if logger:
0246 logger.log_debug("Using rule template", context={"rule": rule})
0247
0248
0249
0250 substitution_context = {
0251 "command": job.command,
0252 "payload": resolved_payload,
0253 **context
0254 }
0255
0256 try:
0257 final_command = _substitute_rule_template(rule, substitution_context)
0258
0259 if logger:
0260 logger.checkpoint(
0261 stage="rule_resolution",
0262 status="success",
0263 message="Rule template resolved to final command",
0264 context={
0265 "rule": rule,
0266 "final_command": final_command,
0267 "command_length": len(final_command)
0268 }
0269 )
0270
0271 return final_command
0272
0273 except Exception as e:
0274 if logger:
0275 logger.checkpoint(
0276 stage="rule_resolution",
0277 status="error",
0278 message=f"Failed to resolve rule: {str(e)}",
0279 context={"rule": rule},
0280 details={"error": str(e), "job_name": job.name}
0281 )
0282 raise RuleResolutionError(f"Cannot resolve rule '{rule}': {str(e)}")
0283
0284
0285 def _substitute_rule_template(rule: str, context: Dict[str, Any]) -> str:
0286 """Substitute variables in rule template.
0287
0288 Handles {{command}}, {{payload[key]}}, and other context variables.
0289
0290 Args:
0291 rule: Rule template string
0292 context: Substitution context
0293
0294 Returns:
0295 Substituted command string
0296 """
0297 result = rule
0298
0299
0300 pattern = r'\{\{([^}]+)\}\}'
0301 variables = re.findall(pattern, rule)
0302
0303 for var in variables:
0304 target = "{{" + f"{var}" + "}}"
0305 value = _get_context_value(var, context)
0306 if value is None:
0307 raise ValueError(f"Undefined variable in rule: {target}")
0308
0309 result = result.replace(target, str(value))
0310
0311
0312 result = re.sub(r'\s+', ' ', result).strip()
0313
0314 return result
0315
0316
0317 def validate_job_payload(
0318 job: JobDefinition,
0319 required_keys: Optional[list[str]] = None,
0320 context: Optional[Dict[str, Any]] = None,
0321 logger: Optional[ExecutionLogger] = None
0322 ) -> Tuple[bool, Optional[str]]:
0323 """Validate job payload before execution.
0324
0325 Checks:
0326 1. Required keys are present in payload
0327 2. Template variables can be resolved (if context provided)
0328 3. Payload values are not empty
0329
0330 Args:
0331 job: JobDefinition to validate
0332 required_keys: List of required payload keys
0333 context: Optional context for template validation
0334 logger: Optional execution logger
0335
0336 Returns:
0337 Tuple of (is_valid, error_message)
0338
0339 Example:
0340 >>> job = JobDefinition(
0341 ... name="test",
0342 ... command="python script.py",
0343 ... payload={"input": "/path/to/input"}
0344 ... )
0345 >>> is_valid, error = validate_job_payload(job, required_keys=["input"])
0346 # is_valid = True, error = None
0347 """
0348 if logger:
0349 logger.checkpoint(
0350 stage="payload_validation",
0351 status="start",
0352 message="Validating job payload",
0353 context={"job_name": job.name, "required_keys": required_keys}
0354 )
0355
0356
0357 if required_keys:
0358 missing_keys = [key for key in required_keys if key not in job.payload]
0359 if missing_keys:
0360 error_msg = f"Missing required payload keys: {missing_keys}"
0361 if logger:
0362 logger.checkpoint(
0363 stage="payload_validation",
0364 status="error",
0365 message=error_msg,
0366 context={"required_keys": required_keys, "missing_keys": missing_keys}
0367 )
0368 return False, error_msg
0369
0370
0371 empty_values = [
0372 (key, value) for key, value in job.payload.items()
0373 if value is None or (isinstance(value, str) and value.strip() == "")
0374 ]
0375
0376 if empty_values:
0377 error_msg = f"Payload contains empty values: {empty_values}"
0378 if logger:
0379 logger.checkpoint(
0380 stage="payload_validation",
0381 status="warning",
0382 message=error_msg,
0383 context={"empty_values": {k: v for k, v in empty_values}}
0384 )
0385
0386
0387
0388 if context:
0389 try:
0390 resolved = resolve_payload_templates(job.payload, context, logger)
0391 if logger:
0392 logger.log_debug("Payload templates resolved during validation")
0393 except RuleResolutionError as e:
0394 error_msg = f"Cannot resolve payload templates: {str(e)}"
0395 if logger:
0396 logger.checkpoint(
0397 stage="payload_validation",
0398 status="error",
0399 message=error_msg,
0400 context={"error": str(e)}
0401 )
0402 return False, error_msg
0403
0404 if logger:
0405 logger.checkpoint(
0406 stage="payload_validation",
0407 status="success",
0408 message="Payload validation passed",
0409 context={"payload_keys": list(job.payload.keys())}
0410 )
0411
0412 return True, None