use super::*;
pub const DSL_DATA_AWARE_FANOUT_METADATA_KEY: &str = "dsl_data_aware_fanout";
pub(crate) const DSL_AUTO_SEPARATE_METADATA_KEY: &str = "auto_separate";
pub fn fan_out_data_aware_branches(
spec: &PipelineDslSpec,
envelope: &crate::data::ExternalDataPlanEnvelope,
) -> Result<PipelineDslSpec> {
envelope.validate()?;
let mut expanded = spec.clone();
let mut fanouts: Vec<DataAwareFanoutRecord> = Vec::new();
let mut id_map: NodeIdRewriteMap = BTreeMap::new();
expanded.steps = fan_out_steps(&spec.steps, envelope, &mut fanouts, &mut id_map)?;
if fanouts.is_empty() {
return Ok(expanded);
}
expanded.data_bindings = rewrite_top_level_data_bindings(&spec.data_bindings, &id_map)?;
reject_generation_overrides_for_fanned_nodes(&spec.generation_dimensions, &id_map)?;
let canonical = serde_json::json!({
"branches": fanouts,
"relation_fingerprint": envelope.relation_fingerprint,
});
let fingerprint = crate::campaign::stable_json_fingerprint(&canonical)?;
expanded.metadata.insert(
DSL_DATA_AWARE_FANOUT_METADATA_KEY.to_string(),
serde_json::json!({
"fingerprint": fingerprint,
"relation_fingerprint": envelope.relation_fingerprint,
"branches": fanouts,
}),
);
Ok(expanded)
}
pub(crate) type NodeIdRewriteMap = BTreeMap<NodeId, Vec<NodeId>>;
pub(crate) fn rewrite_top_level_data_bindings(
bindings: &[DataBinding],
id_map: &NodeIdRewriteMap,
) -> Result<Vec<DataBinding>> {
let mut out = Vec::new();
for binding in bindings {
match id_map.get(&binding.node_id) {
Some(new_ids) => {
for new_id in new_ids {
let mut clone = binding.clone();
clone.node_id = new_id.clone();
out.push(clone);
}
}
None => out.push(binding.clone()),
}
}
Ok(out)
}
pub(crate) fn reject_generation_overrides_for_fanned_nodes(
dimensions: &[PipelineDslGenerationDimension],
id_map: &NodeIdRewriteMap,
) -> Result<()> {
for dimension in dimensions {
for choice in &dimension.choices {
for override_spec in &choice.param_overrides {
if id_map.contains_key(&override_spec.node_id) {
return Err(DagMlError::GraphValidation(format!(
"data-aware fan-out cannot rewrite generation param_override targeting \
node `{}` (generation overrides on a fanned-out template node are not \
supported in this slice)",
override_spec.node_id
)));
}
}
}
}
Ok(())
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct DataAwareFanoutRecord {
branch_step_id: String,
mode: PipelineDslBranchMode,
#[serde(skip_serializing_if = "Option::is_none")]
metadata_key: Option<String>,
values: Vec<serde_json::Value>,
}
pub(crate) fn fan_out_steps(
steps: &[PipelineDslStep],
envelope: &crate::data::ExternalDataPlanEnvelope,
fanouts: &mut Vec<DataAwareFanoutRecord>,
id_map: &mut NodeIdRewriteMap,
) -> Result<Vec<PipelineDslStep>> {
let mut out = Vec::with_capacity(steps.len());
for step in steps {
out.push(fan_out_step(step, envelope, fanouts, id_map)?);
}
Ok(out)
}
pub(crate) fn fan_out_step(
step: &PipelineDslStep,
envelope: &crate::data::ExternalDataPlanEnvelope,
fanouts: &mut Vec<DataAwareFanoutRecord>,
id_map: &mut NodeIdRewriteMap,
) -> Result<PipelineDslStep> {
match step {
PipelineDslStep::Branch(branch_step) => {
if is_auto_separation_branch(branch_step) {
return Ok(PipelineDslStep::Branch(expand_auto_separation_branch(
branch_step,
envelope,
fanouts,
id_map,
)?));
}
let mut expanded = branch_step.clone();
for branch in &mut expanded.branches {
branch.steps = fan_out_steps(&branch.steps, envelope, fanouts, id_map)?;
}
Ok(PipelineDslStep::Branch(expanded))
}
PipelineDslStep::Sequential(sequence) => {
let mut expanded = sequence.clone();
expanded.steps = fan_out_steps(&sequence.steps, envelope, fanouts, id_map)?;
Ok(PipelineDslStep::Sequential(expanded))
}
other => Ok(other.clone()),
}
}
pub(crate) fn is_auto_separation_branch(step: &PipelineDslBranchStep) -> bool {
let marked = step
.metadata
.get(DSL_AUTO_SEPARATE_METADATA_KEY)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
marked
&& matches!(
step.mode,
PipelineDslBranchMode::ByMetadata
| PipelineDslBranchMode::ByTag
| PipelineDslBranchMode::Separation
)
&& step.branches.len() == 1
&& step.branches[0].selector.is_none()
}
pub(crate) fn expand_auto_separation_branch(
step: &PipelineDslBranchStep,
envelope: &crate::data::ExternalDataPlanEnvelope,
fanouts: &mut Vec<DataAwareFanoutRecord>,
id_map: &mut NodeIdRewriteMap,
) -> Result<PipelineDslBranchStep> {
let template = &step.branches[0];
reject_unsupported_template_constructs(&template.id, &template.steps)?;
let relations = envelope.coordinator_relations.as_ref().ok_or_else(|| {
DagMlError::GraphValidation(format!(
"data-aware fan-out of branch `{}` requires coordinator relations in the envelope",
template.id
))
})?;
let (mode, metadata_key, values) = discover_partition_values(step, template, relations)?;
if values.is_empty() {
return Err(DagMlError::GraphValidation(format!(
"data-aware fan-out of branch `{}` discovered no partition values in the relations",
template.id
)));
}
let mut branches = Vec::with_capacity(values.len());
let mut seen_ids = BTreeSet::new();
for value in &values {
let mut branch = template.clone();
let suffix = fanout_value_suffix(value);
branch.id = format!("{}__{suffix}", template.id);
if !seen_ids.insert(branch.id.clone()) {
return Err(DagMlError::GraphValidation(format!(
"data-aware fan-out of branch `{}` produced duplicate branch id `{}` \
(distinct partition values render to the same id)",
template.id, branch.id
)));
}
branch.selector = Some(branch_selector_for_value(
mode,
metadata_key.as_deref(),
value,
));
rewrite_branch_step_ids(&mut branch.steps, &suffix, id_map)?;
branches.push(branch);
}
fanouts.push(DataAwareFanoutRecord {
branch_step_id: template.id.clone(),
mode: step.mode,
metadata_key: metadata_key.clone(),
values,
});
let mut expanded = step.clone();
expanded.metadata.remove(DSL_AUTO_SEPARATE_METADATA_KEY);
expanded.branches = branches;
Ok(expanded)
}
pub(crate) fn discover_partition_values(
step: &PipelineDslBranchStep,
template: &PipelineDslBranch,
relations: &crate::relation::SampleRelationSet,
) -> Result<(BranchViewMode, Option<String>, Vec<serde_json::Value>)> {
let metadata_key = step
.selector
.as_ref()
.and_then(selector_metadata_key)
.or_else(|| template.selector.as_ref().and_then(selector_metadata_key));
let resolved_mode = match step.mode {
PipelineDslBranchMode::ByMetadata => BranchViewMode::ByMetadata,
PipelineDslBranchMode::ByTag => BranchViewMode::ByTag,
PipelineDslBranchMode::Separation => {
if metadata_key.is_some() {
BranchViewMode::ByMetadata
} else {
BranchViewMode::ByTag
}
}
other => {
return Err(DagMlError::GraphValidation(format!(
"data-aware fan-out of branch `{}` does not support mode {other:?}",
template.id
)));
}
};
match resolved_mode {
BranchViewMode::ByMetadata => {
let key = metadata_key.ok_or_else(|| {
DagMlError::GraphValidation(format!(
"data-aware by_metadata fan-out of branch `{}` requires a metadata key on the \
branch step or template selector",
template.id
))
})?;
let mut values: Vec<serde_json::Value> = relations
.records
.iter()
.filter_map(|record| record.metadata.get(&key).cloned())
.collect();
sort_dedup_json_values(&mut values);
Ok((resolved_mode, Some(key), values))
}
BranchViewMode::ByTag => {
let mut tags: Vec<String> = relations
.records
.iter()
.flat_map(|record| record.tags.iter().cloned())
.collect();
tags.sort();
tags.dedup();
let values = tags.into_iter().map(serde_json::Value::String).collect();
Ok((resolved_mode, None, values))
}
other => Err(DagMlError::GraphValidation(format!(
"data-aware fan-out of branch `{}` does not support resolved mode {other:?}",
template.id
))),
}
}
pub(crate) fn branch_selector_for_value(
mode: BranchViewMode,
metadata_key: Option<&str>,
value: &serde_json::Value,
) -> serde_json::Value {
match mode {
BranchViewMode::ByMetadata => {
let key = metadata_key.unwrap_or_default();
serde_json::json!({ "metadata": { key: value.clone() } })
}
_ => serde_json::json!({ "tags": [value.clone()] }),
}
}
pub(crate) fn fanout_value_suffix(value: &serde_json::Value) -> String {
let rendered = match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
};
let sanitized = rendered
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
let sanitized = sanitized.trim_matches('_');
if sanitized.is_empty() {
"empty".to_string()
} else {
sanitized.to_string()
}
}
pub(crate) fn reject_unsupported_template_constructs(
template_id: &str,
steps: &[PipelineDslStep],
) -> Result<()> {
for step in steps {
let unsupported = match step {
PipelineDslStep::Merge(_) => Some("merge"),
PipelineDslStep::MergeModel(_) => Some("merge_model"),
PipelineDslStep::ConcatTransform(_) => Some("concat_transform"),
_ => None,
};
if let Some(kind) = unsupported {
return Err(DagMlError::GraphValidation(format!(
"data-aware fan-out of branch `{template_id}` does not support a `{kind}` step \
inside the auto-separation template (its node-id cross-references cannot be \
safely cloned per partition in this slice)"
)));
}
match step {
PipelineDslStep::Sequential(sequence) => {
reject_unsupported_template_constructs(template_id, &sequence.steps)?;
}
PipelineDslStep::Branch(branch) => {
for nested in &branch.branches {
reject_unsupported_template_constructs(template_id, &nested.steps)?;
}
}
PipelineDslStep::Generator(generator) => {
for nested in &generator.branches {
reject_unsupported_template_constructs(template_id, &nested.steps)?;
}
for stage in &generator.stages {
for nested in &stage.branches {
reject_unsupported_template_constructs(template_id, &nested.steps)?;
}
}
}
_ => {}
}
}
Ok(())
}
pub(crate) fn rewrite_branch_step_ids(
steps: &mut [PipelineDslStep],
suffix: &str,
id_map: &mut NodeIdRewriteMap,
) -> Result<()> {
for step in steps.iter_mut() {
rewrite_step_id(step, suffix, id_map)?;
}
Ok(())
}
pub(crate) fn record_rewrite(id_map: &mut NodeIdRewriteMap, old_id: &NodeId, new_id: &NodeId) {
id_map
.entry(old_id.clone())
.or_default()
.push(new_id.clone());
}
pub(crate) fn suffix_and_record(
id: &NodeId,
suffix: &str,
id_map: &mut NodeIdRewriteMap,
) -> Result<NodeId> {
let new_id = NodeId::new(format!("{}__{suffix}", id.as_str()))?;
record_rewrite(id_map, id, &new_id);
Ok(new_id)
}
pub(crate) fn rewrite_step_id(
step: &mut PipelineDslStep,
suffix: &str,
id_map: &mut NodeIdRewriteMap,
) -> Result<()> {
match step {
PipelineDslStep::Transform(op)
| PipelineDslStep::YTransform(op)
| PipelineDslStep::Tag(op)
| PipelineDslStep::Exclude(op)
| PipelineDslStep::Filter(op)
| PipelineDslStep::SampleFilter(op)
| PipelineDslStep::Augmentation(op)
| PipelineDslStep::FeatureAugmentation(op)
| PipelineDslStep::SampleAugmentation(op)
| PipelineDslStep::DataGeneration(op)
| PipelineDslStep::Model(op)
| PipelineDslStep::Tuner(op)
| PipelineDslStep::Chart(op) => {
op.id = suffix_and_record(&op.id, suffix, id_map)?;
}
PipelineDslStep::ConcatTransform(concat) => {
concat.id = suffix_and_record(&concat.id, suffix, id_map)?;
}
PipelineDslStep::Merge(merge) => {
merge.id = suffix_and_record(&merge.id, suffix, id_map)?;
}
PipelineDslStep::MergeModel(merge) => {
merge.id = suffix_and_record(&merge.id, suffix, id_map)?;
}
PipelineDslStep::Sequential(sequence) => {
if let Some(id) = sequence.id.as_ref() {
sequence.id = Some(suffix_and_record(id, suffix, id_map)?);
}
rewrite_branch_step_ids(&mut sequence.steps, suffix, id_map)?;
}
PipelineDslStep::Branch(branch) => {
for nested in branch.branches.iter_mut() {
rewrite_branch_step_ids(&mut nested.steps, suffix, id_map)?;
}
}
PipelineDslStep::Generator(generator) => {
generator.id = suffix_and_record(&generator.id, suffix, id_map)?;
for nested in generator.branches.iter_mut() {
rewrite_branch_step_ids(&mut nested.steps, suffix, id_map)?;
}
for stage in generator.stages.iter_mut() {
for nested in stage.branches.iter_mut() {
rewrite_branch_step_ids(&mut nested.steps, suffix, id_map)?;
}
}
rewrite_branch_step_ids(&mut generator.tail, suffix, id_map)?;
}
}
Ok(())
}
pub(crate) fn sort_dedup_json_values(values: &mut Vec<serde_json::Value>) {
values.sort_by_key(|value| value.to_string());
values.dedup_by_key(|value| value.to_string());
}