757 lines
24 KiB
Python
757 lines
24 KiB
Python
# artdag/planning/planner.py
|
|
"""
|
|
Recipe planner - converts recipes into execution plans.
|
|
|
|
The planner is the second phase of the 3-phase execution model.
|
|
It takes a recipe and analysis results and generates a complete
|
|
execution plan with pre-computed cache IDs.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import yaml
|
|
|
|
from .schema import ExecutionPlan, ExecutionStep, StepOutput, StepInput, PlanInput
|
|
from .tree_reduction import TreeReducer, reduce_sequence
|
|
from ..analysis import AnalysisResult
|
|
|
|
|
|
def _infer_media_type(node_type: str, config: Dict[str, Any] = None) -> str:
|
|
"""Infer media type from node type and config."""
|
|
config = config or {}
|
|
|
|
# Audio operations
|
|
if node_type in ("AUDIO", "MIX_AUDIO", "EXTRACT_AUDIO"):
|
|
return "audio/wav"
|
|
if "audio" in node_type.lower():
|
|
return "audio/wav"
|
|
|
|
# Image operations
|
|
if node_type in ("FRAME", "THUMBNAIL", "IMAGE"):
|
|
return "image/png"
|
|
|
|
# Default to video
|
|
return "video/mp4"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _stable_hash(data: Any, algorithm: str = "sha3_256") -> str:
|
|
"""Create stable hash from arbitrary data."""
|
|
json_str = json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
hasher = hashlib.new(algorithm)
|
|
hasher.update(json_str.encode())
|
|
return hasher.hexdigest()
|
|
|
|
|
|
@dataclass
|
|
class RecipeNode:
|
|
"""A node in the recipe DAG."""
|
|
id: str
|
|
type: str
|
|
config: Dict[str, Any]
|
|
inputs: List[str]
|
|
|
|
|
|
@dataclass
|
|
class Recipe:
|
|
"""Parsed recipe structure."""
|
|
name: str
|
|
version: str
|
|
description: str
|
|
nodes: List[RecipeNode]
|
|
output: str
|
|
registry: Dict[str, Any]
|
|
owner: str
|
|
raw_yaml: str
|
|
|
|
@property
|
|
def recipe_hash(self) -> str:
|
|
"""Compute hash of recipe content."""
|
|
return _stable_hash({"yaml": self.raw_yaml})
|
|
|
|
@classmethod
|
|
def from_yaml(cls, yaml_content: str) -> "Recipe":
|
|
"""Parse recipe from YAML string."""
|
|
data = yaml.safe_load(yaml_content)
|
|
|
|
nodes = []
|
|
for node_data in data.get("dag", {}).get("nodes", []):
|
|
# Handle both 'inputs' as list and 'inputs' as dict
|
|
inputs = node_data.get("inputs", [])
|
|
if isinstance(inputs, dict):
|
|
# Extract input references from dict structure
|
|
input_list = []
|
|
for key, value in inputs.items():
|
|
if isinstance(value, str):
|
|
input_list.append(value)
|
|
elif isinstance(value, list):
|
|
input_list.extend(value)
|
|
inputs = input_list
|
|
elif isinstance(inputs, str):
|
|
inputs = [inputs]
|
|
|
|
nodes.append(RecipeNode(
|
|
id=node_data["id"],
|
|
type=node_data["type"],
|
|
config=node_data.get("config", {}),
|
|
inputs=inputs,
|
|
))
|
|
|
|
return cls(
|
|
name=data.get("name", "unnamed"),
|
|
version=data.get("version", "1.0"),
|
|
description=data.get("description", ""),
|
|
nodes=nodes,
|
|
output=data.get("dag", {}).get("output", ""),
|
|
registry=data.get("registry", {}),
|
|
owner=data.get("owner", ""),
|
|
raw_yaml=yaml_content,
|
|
)
|
|
|
|
@classmethod
|
|
def from_file(cls, path: Path) -> "Recipe":
|
|
"""Load recipe from YAML file."""
|
|
with open(path, "r") as f:
|
|
return cls.from_yaml(f.read())
|
|
|
|
|
|
class RecipePlanner:
|
|
"""
|
|
Generates execution plans from recipes.
|
|
|
|
The planner:
|
|
1. Parses the recipe
|
|
2. Resolves fixed inputs from registry
|
|
3. Maps variable inputs to provided hashes
|
|
4. Expands MAP/iteration nodes
|
|
5. Applies tree reduction for SEQUENCE nodes
|
|
6. Computes cache IDs for all steps
|
|
"""
|
|
|
|
def __init__(self, use_tree_reduction: bool = True):
|
|
"""
|
|
Initialize the planner.
|
|
|
|
Args:
|
|
use_tree_reduction: Whether to use tree reduction for SEQUENCE
|
|
"""
|
|
self.use_tree_reduction = use_tree_reduction
|
|
|
|
def plan(
|
|
self,
|
|
recipe: Recipe,
|
|
input_hashes: Dict[str, str],
|
|
analysis: Optional[Dict[str, AnalysisResult]] = None,
|
|
seed: Optional[int] = None,
|
|
) -> ExecutionPlan:
|
|
"""
|
|
Generate an execution plan from a recipe.
|
|
|
|
Args:
|
|
recipe: The parsed recipe
|
|
input_hashes: Mapping from input name to content hash
|
|
analysis: Analysis results for inputs (keyed by hash)
|
|
seed: Random seed for deterministic planning
|
|
|
|
Returns:
|
|
ExecutionPlan with pre-computed cache IDs
|
|
"""
|
|
logger.info(f"Planning recipe: {recipe.name}")
|
|
|
|
# Build node lookup
|
|
nodes_by_id = {n.id: n for n in recipe.nodes}
|
|
|
|
# Topologically sort nodes
|
|
sorted_ids = self._topological_sort(recipe.nodes)
|
|
|
|
# Resolve registry references
|
|
registry_hashes = self._resolve_registry(recipe.registry)
|
|
|
|
# Build PlanInput objects from input_hashes
|
|
plan_inputs = []
|
|
for name, cid in input_hashes.items():
|
|
# Try to find matching SOURCE node for media type
|
|
media_type = "application/octet-stream"
|
|
for node in recipe.nodes:
|
|
if node.id == name and node.type == "SOURCE":
|
|
media_type = _infer_media_type("SOURCE", node.config)
|
|
break
|
|
|
|
plan_inputs.append(PlanInput(
|
|
name=name,
|
|
cache_id=cid,
|
|
cid=cid,
|
|
media_type=media_type,
|
|
))
|
|
|
|
# Generate steps
|
|
steps = []
|
|
step_id_map = {} # Maps recipe node ID to step ID(s)
|
|
step_name_map = {} # Maps recipe node ID to human-readable name
|
|
analysis_cache_ids = {}
|
|
|
|
for node_id in sorted_ids:
|
|
node = nodes_by_id[node_id]
|
|
logger.debug(f"Processing node: {node.id} ({node.type})")
|
|
|
|
new_steps, output_step_id = self._process_node(
|
|
node=node,
|
|
step_id_map=step_id_map,
|
|
step_name_map=step_name_map,
|
|
input_hashes=input_hashes,
|
|
registry_hashes=registry_hashes,
|
|
analysis=analysis or {},
|
|
recipe_name=recipe.name,
|
|
)
|
|
|
|
steps.extend(new_steps)
|
|
step_id_map[node_id] = output_step_id
|
|
# Track human-readable name for this node
|
|
if new_steps:
|
|
step_name_map[node_id] = new_steps[-1].name
|
|
|
|
# Find output step
|
|
output_step = step_id_map.get(recipe.output)
|
|
if not output_step:
|
|
raise ValueError(f"Output node '{recipe.output}' not found")
|
|
|
|
# Determine output name
|
|
output_name = f"{recipe.name}.output"
|
|
output_step_obj = next((s for s in steps if s.step_id == output_step), None)
|
|
if output_step_obj and output_step_obj.outputs:
|
|
output_name = output_step_obj.outputs[0].name
|
|
|
|
# Build analysis cache IDs
|
|
if analysis:
|
|
analysis_cache_ids = {
|
|
h: a.cache_id for h, a in analysis.items()
|
|
if a.cache_id
|
|
}
|
|
|
|
# Create plan
|
|
plan = ExecutionPlan(
|
|
plan_id=None, # Computed in __post_init__
|
|
name=f"{recipe.name}_plan",
|
|
recipe_id=recipe.name,
|
|
recipe_name=recipe.name,
|
|
recipe_hash=recipe.recipe_hash,
|
|
seed=seed,
|
|
inputs=plan_inputs,
|
|
steps=steps,
|
|
output_step=output_step,
|
|
output_name=output_name,
|
|
analysis_cache_ids=analysis_cache_ids,
|
|
input_hashes=input_hashes,
|
|
metadata={
|
|
"recipe_version": recipe.version,
|
|
"recipe_description": recipe.description,
|
|
"owner": recipe.owner,
|
|
},
|
|
)
|
|
|
|
# Compute all cache IDs and then generate outputs
|
|
plan.compute_all_cache_ids()
|
|
plan.compute_levels()
|
|
|
|
# Now add outputs to each step (needs cache_id to be computed first)
|
|
self._add_step_outputs(plan, recipe.name)
|
|
|
|
# Recompute plan_id after outputs are added
|
|
plan.plan_id = plan._compute_plan_id()
|
|
|
|
logger.info(f"Generated plan with {len(steps)} steps")
|
|
return plan
|
|
|
|
def _add_step_outputs(self, plan: ExecutionPlan, recipe_name: str) -> None:
|
|
"""Add output definitions to each step after cache_ids are computed."""
|
|
for step in plan.steps:
|
|
if step.outputs:
|
|
continue # Already has outputs
|
|
|
|
# Generate output name from step name
|
|
base_name = step.name or step.step_id
|
|
output_name = f"{recipe_name}.{base_name}.out"
|
|
|
|
media_type = _infer_media_type(step.node_type, step.config)
|
|
|
|
step.add_output(
|
|
name=output_name,
|
|
media_type=media_type,
|
|
index=0,
|
|
metadata={},
|
|
)
|
|
|
|
def plan_from_yaml(
|
|
self,
|
|
yaml_content: str,
|
|
input_hashes: Dict[str, str],
|
|
analysis: Optional[Dict[str, AnalysisResult]] = None,
|
|
) -> ExecutionPlan:
|
|
"""
|
|
Generate plan from YAML string.
|
|
|
|
Args:
|
|
yaml_content: Recipe YAML content
|
|
input_hashes: Mapping from input name to content hash
|
|
analysis: Analysis results
|
|
|
|
Returns:
|
|
ExecutionPlan
|
|
"""
|
|
recipe = Recipe.from_yaml(yaml_content)
|
|
return self.plan(recipe, input_hashes, analysis)
|
|
|
|
def plan_from_file(
|
|
self,
|
|
recipe_path: Path,
|
|
input_hashes: Dict[str, str],
|
|
analysis: Optional[Dict[str, AnalysisResult]] = None,
|
|
) -> ExecutionPlan:
|
|
"""
|
|
Generate plan from recipe file.
|
|
|
|
Args:
|
|
recipe_path: Path to recipe YAML file
|
|
input_hashes: Mapping from input name to content hash
|
|
analysis: Analysis results
|
|
|
|
Returns:
|
|
ExecutionPlan
|
|
"""
|
|
recipe = Recipe.from_file(recipe_path)
|
|
return self.plan(recipe, input_hashes, analysis)
|
|
|
|
def _topological_sort(self, nodes: List[RecipeNode]) -> List[str]:
|
|
"""Topologically sort recipe nodes."""
|
|
nodes_by_id = {n.id: n for n in nodes}
|
|
visited = set()
|
|
order = []
|
|
|
|
def visit(node_id: str):
|
|
if node_id in visited:
|
|
return
|
|
if node_id not in nodes_by_id:
|
|
return # External input
|
|
visited.add(node_id)
|
|
node = nodes_by_id[node_id]
|
|
for input_id in node.inputs:
|
|
visit(input_id)
|
|
order.append(node_id)
|
|
|
|
for node in nodes:
|
|
visit(node.id)
|
|
|
|
return order
|
|
|
|
def _resolve_registry(self, registry: Dict[str, Any]) -> Dict[str, str]:
|
|
"""
|
|
Resolve registry references to content hashes.
|
|
|
|
Args:
|
|
registry: Registry section from recipe
|
|
|
|
Returns:
|
|
Mapping from name to content hash
|
|
"""
|
|
hashes = {}
|
|
|
|
# Assets
|
|
for name, asset_data in registry.get("assets", {}).items():
|
|
if isinstance(asset_data, dict) and "hash" in asset_data:
|
|
hashes[name] = asset_data["hash"]
|
|
elif isinstance(asset_data, str):
|
|
hashes[name] = asset_data
|
|
|
|
# Effects
|
|
for name, effect_data in registry.get("effects", {}).items():
|
|
if isinstance(effect_data, dict) and "hash" in effect_data:
|
|
hashes[f"effect:{name}"] = effect_data["hash"]
|
|
elif isinstance(effect_data, str):
|
|
hashes[f"effect:{name}"] = effect_data
|
|
|
|
return hashes
|
|
|
|
def _process_node(
|
|
self,
|
|
node: RecipeNode,
|
|
step_id_map: Dict[str, str],
|
|
step_name_map: Dict[str, str],
|
|
input_hashes: Dict[str, str],
|
|
registry_hashes: Dict[str, str],
|
|
analysis: Dict[str, AnalysisResult],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""
|
|
Process a recipe node into execution steps.
|
|
|
|
Args:
|
|
node: Recipe node to process
|
|
step_id_map: Mapping from processed node IDs to step IDs
|
|
step_name_map: Mapping from node IDs to human-readable names
|
|
input_hashes: User-provided input hashes
|
|
registry_hashes: Registry-resolved hashes
|
|
analysis: Analysis results
|
|
recipe_name: Name of the recipe (for generating readable names)
|
|
|
|
Returns:
|
|
Tuple of (new steps, output step ID)
|
|
"""
|
|
# SOURCE nodes
|
|
if node.type == "SOURCE":
|
|
return self._process_source(node, input_hashes, registry_hashes, recipe_name)
|
|
|
|
# SOURCE_LIST nodes
|
|
if node.type == "SOURCE_LIST":
|
|
return self._process_source_list(node, input_hashes, recipe_name)
|
|
|
|
# ANALYZE nodes
|
|
if node.type == "ANALYZE":
|
|
return self._process_analyze(node, step_id_map, analysis, recipe_name)
|
|
|
|
# MAP nodes
|
|
if node.type == "MAP":
|
|
return self._process_map(node, step_id_map, input_hashes, analysis, recipe_name)
|
|
|
|
# SEQUENCE nodes (may use tree reduction)
|
|
if node.type == "SEQUENCE":
|
|
return self._process_sequence(node, step_id_map, recipe_name)
|
|
|
|
# SEGMENT_AT nodes
|
|
if node.type == "SEGMENT_AT":
|
|
return self._process_segment_at(node, step_id_map, analysis, recipe_name)
|
|
|
|
# Standard nodes (SEGMENT, RESIZE, TRANSFORM, LAYER, MUX, BLEND, etc.)
|
|
return self._process_standard(node, step_id_map, recipe_name)
|
|
|
|
def _process_source(
|
|
self,
|
|
node: RecipeNode,
|
|
input_hashes: Dict[str, str],
|
|
registry_hashes: Dict[str, str],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""Process SOURCE node."""
|
|
config = dict(node.config)
|
|
|
|
# Variable input?
|
|
if config.get("input"):
|
|
# Look up in user-provided inputs
|
|
if node.id not in input_hashes:
|
|
raise ValueError(f"Missing input for SOURCE node '{node.id}'")
|
|
cid = input_hashes[node.id]
|
|
# Fixed asset from registry?
|
|
elif config.get("asset"):
|
|
asset_name = config["asset"]
|
|
if asset_name not in registry_hashes:
|
|
raise ValueError(f"Asset '{asset_name}' not found in registry")
|
|
cid = registry_hashes[asset_name]
|
|
else:
|
|
raise ValueError(f"SOURCE node '{node.id}' has no input or asset")
|
|
|
|
# Human-readable name
|
|
display_name = config.get("name", node.id)
|
|
step_name = f"{recipe_name}.inputs.{display_name}" if recipe_name else display_name
|
|
|
|
step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type="SOURCE",
|
|
config={"input_ref": node.id, "cid": cid},
|
|
input_steps=[],
|
|
cache_id=cid, # SOURCE cache_id is just the content hash
|
|
name=step_name,
|
|
)
|
|
|
|
return [step], step.step_id
|
|
|
|
def _process_source_list(
|
|
self,
|
|
node: RecipeNode,
|
|
input_hashes: Dict[str, str],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""
|
|
Process SOURCE_LIST node.
|
|
|
|
Creates individual SOURCE steps for each item in the list.
|
|
"""
|
|
# Look for list input
|
|
if node.id not in input_hashes:
|
|
raise ValueError(f"Missing input for SOURCE_LIST node '{node.id}'")
|
|
|
|
input_value = input_hashes[node.id]
|
|
|
|
# Parse as comma-separated list if string
|
|
if isinstance(input_value, str):
|
|
items = [h.strip() for h in input_value.split(",")]
|
|
else:
|
|
items = list(input_value)
|
|
|
|
display_name = node.config.get("name", node.id)
|
|
base_name = f"{recipe_name}.{display_name}" if recipe_name else display_name
|
|
|
|
steps = []
|
|
for i, cid in enumerate(items):
|
|
step = ExecutionStep(
|
|
step_id=f"{node.id}_{i}",
|
|
node_type="SOURCE",
|
|
config={"input_ref": f"{node.id}[{i}]", "cid": cid},
|
|
input_steps=[],
|
|
cache_id=cid,
|
|
name=f"{base_name}[{i}]",
|
|
)
|
|
steps.append(step)
|
|
|
|
# Return list marker as output
|
|
list_step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type="_LIST",
|
|
config={"items": [s.step_id for s in steps]},
|
|
input_steps=[s.step_id for s in steps],
|
|
name=f"{base_name}.list",
|
|
)
|
|
steps.append(list_step)
|
|
|
|
return steps, list_step.step_id
|
|
|
|
def _process_analyze(
|
|
self,
|
|
node: RecipeNode,
|
|
step_id_map: Dict[str, str],
|
|
analysis: Dict[str, AnalysisResult],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""
|
|
Process ANALYZE node.
|
|
|
|
ANALYZE nodes reference pre-computed analysis results.
|
|
"""
|
|
input_step = step_id_map.get(node.inputs[0]) if node.inputs else None
|
|
if not input_step:
|
|
raise ValueError(f"ANALYZE node '{node.id}' has no input")
|
|
|
|
feature = node.config.get("feature", "all")
|
|
step_name = f"{recipe_name}.analysis.{feature}" if recipe_name else f"analysis.{feature}"
|
|
|
|
step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type="ANALYZE",
|
|
config={
|
|
"feature": feature,
|
|
**node.config,
|
|
},
|
|
input_steps=[input_step],
|
|
name=step_name,
|
|
)
|
|
|
|
return [step], step.step_id
|
|
|
|
def _process_map(
|
|
self,
|
|
node: RecipeNode,
|
|
step_id_map: Dict[str, str],
|
|
input_hashes: Dict[str, str],
|
|
analysis: Dict[str, AnalysisResult],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""
|
|
Process MAP node - expand iteration over list.
|
|
|
|
MAP applies an operation to each item in a list.
|
|
"""
|
|
operation = node.config.get("operation", "TRANSFORM")
|
|
base_name = f"{recipe_name}.{node.id}" if recipe_name else node.id
|
|
|
|
# Get items input
|
|
items_ref = node.config.get("items") or (
|
|
node.inputs[0] if isinstance(node.inputs, list) else
|
|
node.inputs.get("items") if isinstance(node.inputs, dict) else None
|
|
)
|
|
|
|
if not items_ref:
|
|
raise ValueError(f"MAP node '{node.id}' has no items input")
|
|
|
|
# Resolve items to list of step IDs
|
|
if items_ref in step_id_map:
|
|
# Reference to SOURCE_LIST output
|
|
items_step = step_id_map[items_ref]
|
|
# TODO: expand list items
|
|
logger.warning(f"MAP node '{node.id}' references list step, expansion TBD")
|
|
item_steps = [items_step]
|
|
else:
|
|
item_steps = [items_ref]
|
|
|
|
# Generate step for each item
|
|
steps = []
|
|
output_steps = []
|
|
|
|
for i, item_step in enumerate(item_steps):
|
|
step_id = f"{node.id}_{i}"
|
|
|
|
if operation == "RANDOM_SLICE":
|
|
step = ExecutionStep(
|
|
step_id=step_id,
|
|
node_type="SEGMENT",
|
|
config={
|
|
"random": True,
|
|
"seed_from": node.config.get("seed_from"),
|
|
"index": i,
|
|
},
|
|
input_steps=[item_step],
|
|
name=f"{base_name}.slice[{i}]",
|
|
)
|
|
elif operation == "TRANSFORM":
|
|
step = ExecutionStep(
|
|
step_id=step_id,
|
|
node_type="TRANSFORM",
|
|
config=node.config.get("effects", {}),
|
|
input_steps=[item_step],
|
|
name=f"{base_name}.transform[{i}]",
|
|
)
|
|
elif operation == "ANALYZE":
|
|
step = ExecutionStep(
|
|
step_id=step_id,
|
|
node_type="ANALYZE",
|
|
config={"feature": node.config.get("feature", "all")},
|
|
input_steps=[item_step],
|
|
name=f"{base_name}.analyze[{i}]",
|
|
)
|
|
else:
|
|
step = ExecutionStep(
|
|
step_id=step_id,
|
|
node_type=operation,
|
|
config=node.config,
|
|
input_steps=[item_step],
|
|
name=f"{base_name}.{operation.lower()}[{i}]",
|
|
)
|
|
|
|
steps.append(step)
|
|
output_steps.append(step_id)
|
|
|
|
# Create list output
|
|
list_step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type="_LIST",
|
|
config={"items": output_steps},
|
|
input_steps=output_steps,
|
|
name=f"{base_name}.results",
|
|
)
|
|
steps.append(list_step)
|
|
|
|
return steps, list_step.step_id
|
|
|
|
def _process_sequence(
|
|
self,
|
|
node: RecipeNode,
|
|
step_id_map: Dict[str, str],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""
|
|
Process SEQUENCE node.
|
|
|
|
Uses tree reduction for parallel composition if enabled.
|
|
"""
|
|
base_name = f"{recipe_name}.{node.id}" if recipe_name else node.id
|
|
|
|
# Resolve input steps
|
|
input_steps = []
|
|
for input_id in node.inputs:
|
|
if input_id in step_id_map:
|
|
input_steps.append(step_id_map[input_id])
|
|
else:
|
|
input_steps.append(input_id)
|
|
|
|
if len(input_steps) == 0:
|
|
raise ValueError(f"SEQUENCE node '{node.id}' has no inputs")
|
|
|
|
if len(input_steps) == 1:
|
|
# Single input, no sequence needed
|
|
return [], input_steps[0]
|
|
|
|
transition_config = node.config.get("transition", {"type": "cut"})
|
|
config = {"transition": transition_config}
|
|
|
|
if self.use_tree_reduction and len(input_steps) > 2:
|
|
# Use tree reduction
|
|
reduction_steps, output_id = reduce_sequence(
|
|
input_steps,
|
|
transition_config=config,
|
|
id_prefix=node.id,
|
|
)
|
|
|
|
steps = []
|
|
for i, (step_id, inputs, step_config) in enumerate(reduction_steps):
|
|
step = ExecutionStep(
|
|
step_id=step_id,
|
|
node_type="SEQUENCE",
|
|
config=step_config,
|
|
input_steps=inputs,
|
|
name=f"{base_name}.reduce[{i}]",
|
|
)
|
|
steps.append(step)
|
|
|
|
return steps, output_id
|
|
else:
|
|
# Direct sequence
|
|
step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type="SEQUENCE",
|
|
config=config,
|
|
input_steps=input_steps,
|
|
name=f"{base_name}.concat",
|
|
)
|
|
return [step], step.step_id
|
|
|
|
def _process_segment_at(
|
|
self,
|
|
node: RecipeNode,
|
|
step_id_map: Dict[str, str],
|
|
analysis: Dict[str, AnalysisResult],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""
|
|
Process SEGMENT_AT node - cut at specific times.
|
|
|
|
Creates SEGMENT steps for each time range.
|
|
"""
|
|
base_name = f"{recipe_name}.{node.id}" if recipe_name else node.id
|
|
times_from = node.config.get("times_from")
|
|
distribute = node.config.get("distribute", "round_robin")
|
|
|
|
# TODO: Resolve times from analysis
|
|
# For now, create a placeholder
|
|
step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type="SEGMENT_AT",
|
|
config=node.config,
|
|
input_steps=[step_id_map.get(i, i) for i in node.inputs],
|
|
name=f"{base_name}.segment",
|
|
)
|
|
|
|
return [step], step.step_id
|
|
|
|
def _process_standard(
|
|
self,
|
|
node: RecipeNode,
|
|
step_id_map: Dict[str, str],
|
|
recipe_name: str = "",
|
|
) -> Tuple[List[ExecutionStep], str]:
|
|
"""Process standard transformation/composition node."""
|
|
base_name = f"{recipe_name}.{node.id}" if recipe_name else node.id
|
|
input_steps = [step_id_map.get(i, i) for i in node.inputs]
|
|
|
|
step = ExecutionStep(
|
|
step_id=node.id,
|
|
node_type=node.type,
|
|
config=node.config,
|
|
input_steps=input_steps,
|
|
name=f"{base_name}.{node.type.lower()}",
|
|
)
|
|
|
|
return [step], step.step_id
|