use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum MergeReduction {
Concat,
Fusion,
FusionProbaMean,
}
pub(crate) fn merge_reduction_mode(
plan: &ExecutionPlan,
node_plan: &NodePlan,
) -> Option<MergeReduction> {
if node_plan.kind != crate::graph::NodeKind::PredictionJoin {
return None;
}
match plan
.graph_plan
.graph
.nodes
.iter()
.find(|node| node.id == node_plan.node_id)
.and_then(|node| node.metadata.get("merge_mode"))
.and_then(serde_json::Value::as_str)
{
Some("concat") => Some(MergeReduction::Concat),
Some("fusion") => Some(MergeReduction::Fusion),
Some("fusion_proba_mean") => Some(MergeReduction::FusionProbaMean),
_ => None,
}
}
pub(crate) fn reassemble_branch_merge(
plan: &ExecutionPlan,
node_plan: &NodePlan,
ctx: &RunContext,
scope: &PhaseScope,
reduction: MergeReduction,
) -> Result<Option<NodeResult>> {
if scope.phase != Phase::FitCv {
return reassemble_branch_merge_off_fold(node_plan, ctx, scope, reduction);
}
match reduction {
MergeReduction::Concat => reassemble_separation_merge(plan, node_plan, ctx, scope),
MergeReduction::Fusion | MergeReduction::FusionProbaMean => {
reassemble_fusion_merge(plan, node_plan, ctx, scope, reduction)
}
}
}
pub(crate) fn expected_off_fold_partition(phase: Phase) -> PredictionPartition {
match phase {
Phase::Predict => PredictionPartition::Final,
_ => PredictionPartition::Test,
}
}
pub(crate) fn reassemble_branch_merge_off_fold(
node_plan: &NodePlan,
ctx: &RunContext,
scope: &PhaseScope,
reduction: MergeReduction,
) -> Result<Option<NodeResult>> {
let variant_id = scope.variant_id.clone();
let expected_partition = expected_off_fold_partition(scope.phase);
let mut branch_blocks: Vec<PredictionBlock> = Vec::new();
let mut partition: Option<PredictionPartition> = None;
let mut by_sample_target: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
let mut target_block_names: Option<Vec<String>> = None;
for branch_id in &node_plan.input_nodes {
let blocks: Vec<&PredictionBlock> = ctx
.prediction_store
.find(Some(branch_id), Some(&expected_partition), None)
.into_iter()
.filter(|block| block.fold_id.is_none())
.collect();
if blocks.is_empty() {
continue;
}
if blocks.len() > 1 {
return Err(DagMlError::OofValidation(format!(
"merge node `{}` found {} off-fold ({expected_partition:?}) blocks for branch `{branch_id}`: the run context mixes several variants — reassemble each variant in its own context (native SELECT does this)",
node_plan.node_id,
blocks.len(),
)));
}
let block = blocks[0];
block.validate_shape()?;
match &partition {
None => partition = Some(block.partition.clone()),
Some(existing) if existing != &block.partition => {
return Err(DagMlError::OofValidation(format!(
"merge node `{}` received mismatched off-fold partitions ({existing:?} vs {:?}) from branch `{branch_id}`",
node_plan.node_id, block.partition
)));
}
_ => {}
}
branch_blocks.push(block.clone());
for record in &ctx.regression_target_records {
if &record.producer_node != branch_id
|| record.fold_id.is_some()
|| record.partition != expected_partition
|| record.variant_id != variant_id
{
continue;
}
if target_block_names.is_none() && !record.block.target_names.is_empty() {
target_block_names = Some(record.block.target_names.clone());
}
for (unit_id, row) in record.block.unit_ids.iter().zip(&record.block.values) {
let PredictionUnitId::Sample(sample_id) = unit_id else {
continue;
};
by_sample_target.insert(sample_id.clone(), row.clone());
}
}
}
if branch_blocks.is_empty() {
return Ok(None);
}
let partition = partition.expect("at least one branch block present");
let reassembled = match reduction {
MergeReduction::Concat => reassemble_off_fold_concat(&branch_blocks, &node_plan.node_id)?,
MergeReduction::Fusion => {
reduce_predictions_across_branches(&branch_blocks, None, &node_plan.node_id)?
}
MergeReduction::FusionProbaMean => {
reduce_proba_mean_across_branches(&branch_blocks, &node_plan.node_id)?
}
};
let mut sample_ids: Vec<SampleId> = reassembled.sample_ids.clone();
sample_ids.sort();
let by_sample: BTreeMap<&SampleId, &Vec<f64>> = reassembled
.sample_ids
.iter()
.zip(&reassembled.values)
.collect();
let values: Vec<Vec<f64>> = sample_ids
.iter()
.map(|sample_id| by_sample[sample_id].clone())
.collect();
let regression_targets = reassemble_merge_targets(
&node_plan.node_id,
&sample_ids,
&mut by_sample_target,
target_block_names.unwrap_or_default(),
)?
.into_iter()
.collect();
let branch_inputs: BTreeSet<&NodeId> = node_plan.input_nodes.iter().collect();
let mut input_lineage: Vec<LineageId> = Vec::new();
for record in ctx.lineage.records() {
if branch_inputs.contains(&record.node_id)
&& record.phase == scope.phase
&& record.fold_id.is_none()
&& record.variant_id == variant_id
{
input_lineage.push(record.record_id.clone());
}
}
let variant_suffix = variant_id
.as_ref()
.map(|variant| format!(":{variant}"))
.unwrap_or_default();
let phase_label = scope.phase.as_str();
let merged = PredictionBlock {
prediction_id: Some(format!(
"merge:{}:{phase_label}{variant_suffix}",
node_plan.node_id
)),
producer_node: node_plan.node_id.clone(),
partition,
fold_id: None,
sample_ids,
values,
target_names: reassembled.target_names.clone(),
};
merged.validate_shape()?;
let lineage = LineageRecord {
record_id: LineageId::new(format!(
"lineage:{}:{phase_label}{variant_suffix}",
node_plan.node_id
))?,
run_id: ctx.run_id.clone(),
node_id: node_plan.node_id.clone(),
phase: scope.phase,
controller_id: node_plan.controller_id.clone(),
controller_version: node_plan.controller_version.clone(),
variant_id,
fold_id: None,
branch_path: Vec::new(),
input_lineage,
artifact_refs: Vec::new(),
params_fingerprint: node_plan.params_fingerprint.clone(),
data_model_shape_fingerprint: None,
aggregation_policy_fingerprint: None,
seed: None,
unsafe_flags: BTreeSet::new(),
metrics: BTreeMap::new(),
};
Ok(Some(NodeResult {
node_id: node_plan.node_id.clone(),
outputs: BTreeMap::new(),
predictions: vec![merged],
observation_predictions: Vec::new(),
aggregated_predictions: Vec::new(),
explanations: Vec::new(),
shape_deltas: Vec::new(),
artifacts: Vec::new(),
artifact_handles: BTreeMap::new(),
fit_influence_diagnostics: Vec::new(),
regression_targets,
lineage,
}))
}
pub(crate) fn reassemble_off_fold_concat(
branch_blocks: &[PredictionBlock],
merge_node: &NodeId,
) -> Result<PredictionBlock> {
let first = branch_blocks
.first()
.expect("at least one branch block present");
let width = first.validate_shape()?;
let target_names = if first.target_names.is_empty() {
(0..width).map(|idx| format!("p{idx}")).collect::<Vec<_>>()
} else {
first.target_names.clone()
};
let mut by_sample: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
for block in branch_blocks {
let block_width = block.validate_shape()?;
if block_width != width {
return Err(DagMlError::OofValidation(format!(
"merge node `{merge_node}` received mismatched off-fold prediction widths ({width} vs {block_width})"
)));
}
let block_targets = if block.target_names.is_empty() {
(0..block_width).map(|idx| format!("p{idx}")).collect()
} else {
block.target_names.clone()
};
if block_targets != target_names {
return Err(DagMlError::OofValidation(format!(
"merge node `{merge_node}` received inconsistent off-fold target names across branches"
)));
}
for (sample_id, row) in block.sample_ids.iter().zip(&block.values) {
if by_sample.insert(sample_id.clone(), row.clone()).is_some() {
return Err(DagMlError::OofValidation(format!(
"merge node `{merge_node}` received overlapping off-fold branch predictions: sample `{sample_id}` is covered by more than one partition"
)));
}
}
}
let sample_ids: Vec<SampleId> = by_sample.keys().cloned().collect();
let values: Vec<Vec<f64>> = sample_ids
.iter()
.map(|sample_id| by_sample[sample_id].clone())
.collect();
Ok(PredictionBlock {
prediction_id: None,
producer_node: merge_node.clone(),
partition: first.partition.clone(),
fold_id: None,
sample_ids,
values,
target_names,
})
}
pub(crate) fn reassemble_separation_merge(
plan: &ExecutionPlan,
node_plan: &NodePlan,
ctx: &RunContext,
scope: &PhaseScope,
) -> Result<Option<NodeResult>> {
let Some(fold_id) = scope.fold_id.clone() else {
return Ok(None);
};
let fold = plan
.fold_set
.as_ref()
.and_then(|fold_set| fold_set.folds.iter().find(|fold| fold.fold_id == fold_id))
.ok_or_else(|| {
DagMlError::RuntimeValidation(format!(
"merge node `{}` references unknown fold `{fold_id}`",
node_plan.node_id
))
})?;
let expected: BTreeSet<&SampleId> = fold.validation_sample_ids.iter().collect();
if expected.is_empty() {
return Ok(None);
}
let variant_id = scope.variant_id.clone();
let mut by_sample: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
let mut by_sample_target: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
let mut target_names: Option<Vec<String>> = None;
let mut target_block_names: Option<Vec<String>> = None;
let mut width: Option<usize> = None;
for branch_id in &node_plan.input_nodes {
let blocks = ctx.prediction_store.find(
Some(branch_id),
Some(&PredictionPartition::Validation),
Some(&fold_id),
);
if blocks.is_empty() {
continue;
}
if blocks.len() > 1 {
return Err(DagMlError::OofValidation(format!(
"merge node `{}` found {} validation blocks for branch `{branch_id}` in fold `{fold_id}`: the run context mixes several variants — reassemble each variant in its own context (native SELECT does this)",
node_plan.node_id,
blocks.len()
)));
}
let block = blocks[0];
let block_width = block.validate_shape()?;
match width {
None => width = Some(block_width),
Some(existing) if existing != block_width => {
return Err(DagMlError::OofValidation(format!(
"merge node `{}` received mismatched prediction widths ({existing} vs {block_width}) from branch `{branch_id}`",
node_plan.node_id
)));
}
_ => {}
}
let block_targets = if block.target_names.is_empty() {
(0..block_width).map(|idx| format!("p{idx}")).collect()
} else {
block.target_names.clone()
};
match &target_names {
None => target_names = Some(block_targets),
Some(existing) if existing != &block_targets => {
return Err(DagMlError::OofValidation(format!(
"merge node `{}` received inconsistent target names across branches",
node_plan.node_id
)));
}
_ => {}
}
for (sample_id, values) in block.sample_ids.iter().zip(block.values.iter()) {
if !expected.contains(sample_id) {
return Err(DagMlError::OofValidation(format!(
"merge node `{}` branch `{branch_id}` emitted sample `{sample_id}` outside fold `{fold_id}` validation set",
node_plan.node_id
)));
}
if by_sample
.insert(sample_id.clone(), values.clone())
.is_some()
{
return Err(DagMlError::OofValidation(format!(
"merge node `{}` received overlapping branch predictions: sample `{sample_id}` is covered by more than one partition",
node_plan.node_id
)));
}
}
for record in &ctx.regression_target_records {
if &record.producer_node != branch_id
|| record.partition != PredictionPartition::Validation
|| record.fold_id.as_ref() != Some(&fold_id)
|| record.variant_id != variant_id
{
continue;
}
if target_block_names.is_none() && !record.block.target_names.is_empty() {
target_block_names = Some(record.block.target_names.clone());
}
for (unit_id, row) in record.block.unit_ids.iter().zip(&record.block.values) {
let PredictionUnitId::Sample(sample_id) = unit_id else {
continue;
};
by_sample_target.insert(sample_id.clone(), row.clone());
}
}
}
let covered: BTreeSet<&SampleId> = by_sample.keys().collect();
if covered != expected {
let missing: Vec<String> = expected
.difference(&covered)
.map(|sample| sample.to_string())
.collect();
return Err(DagMlError::OofValidation(format!(
"merge node `{}` reassembled OOF does not cover fold `{fold_id}` validation set (missing {} sample(s): {})",
node_plan.node_id,
missing.len(),
missing.join(", ")
)));
}
let sample_ids: Vec<SampleId> = fold.validation_sample_ids.clone();
let values: Vec<Vec<f64>> = sample_ids
.iter()
.map(|sample_id| by_sample.remove(sample_id).expect("sample covered"))
.collect();
let target_names = target_names.unwrap_or_default();
let regression_targets = reassemble_merge_targets(
&node_plan.node_id,
&sample_ids,
&mut by_sample_target,
target_block_names.unwrap_or_default(),
)?
.into_iter()
.collect();
let branch_inputs: BTreeSet<&NodeId> = node_plan.input_nodes.iter().collect();
let mut input_lineage: Vec<LineageId> = Vec::new();
for record in ctx.lineage.records() {
if branch_inputs.contains(&record.node_id)
&& record.phase == scope.phase
&& record.fold_id.as_ref() == Some(&fold_id)
&& record.variant_id == variant_id
{
input_lineage.push(record.record_id.clone());
}
}
let variant_suffix = variant_id
.as_ref()
.map(|variant| format!(":{variant}"))
.unwrap_or_default();
let merged = PredictionBlock {
prediction_id: Some(format!(
"merge:{}:{fold_id}{variant_suffix}",
node_plan.node_id
)),
producer_node: node_plan.node_id.clone(),
partition: PredictionPartition::Validation,
fold_id: Some(fold_id.clone()),
sample_ids,
values,
target_names,
};
merged.validate_shape()?;
let lineage = LineageRecord {
record_id: LineageId::new(format!(
"lineage:{}:{fold_id}{variant_suffix}",
node_plan.node_id
))?,
run_id: ctx.run_id.clone(),
node_id: node_plan.node_id.clone(),
phase: scope.phase,
controller_id: node_plan.controller_id.clone(),
controller_version: node_plan.controller_version.clone(),
variant_id,
fold_id: Some(fold_id),
branch_path: Vec::new(),
input_lineage,
artifact_refs: Vec::new(),
params_fingerprint: node_plan.params_fingerprint.clone(),
data_model_shape_fingerprint: None,
aggregation_policy_fingerprint: None,
seed: None,
unsafe_flags: BTreeSet::new(),
metrics: BTreeMap::new(),
};
Ok(Some(NodeResult {
node_id: node_plan.node_id.clone(),
outputs: BTreeMap::new(),
predictions: vec![merged],
observation_predictions: Vec::new(),
aggregated_predictions: Vec::new(),
explanations: Vec::new(),
shape_deltas: Vec::new(),
artifacts: Vec::new(),
artifact_handles: BTreeMap::new(),
fit_influence_diagnostics: Vec::new(),
regression_targets,
lineage,
}))
}
pub(crate) fn reassemble_fusion_merge(
plan: &ExecutionPlan,
node_plan: &NodePlan,
ctx: &RunContext,
scope: &PhaseScope,
reduction: MergeReduction,
) -> Result<Option<NodeResult>> {
let Some(fold_id) = scope.fold_id.clone() else {
return Ok(None);
};
let fold = plan
.fold_set
.as_ref()
.and_then(|fold_set| fold_set.folds.iter().find(|fold| fold.fold_id == fold_id))
.ok_or_else(|| {
DagMlError::RuntimeValidation(format!(
"fusion merge node `{}` references unknown fold `{fold_id}`",
node_plan.node_id
))
})?;
let expected: BTreeSet<&SampleId> = fold.validation_sample_ids.iter().collect();
if expected.is_empty() {
return Ok(None);
}
let variant_id = scope.variant_id.clone();
let mut branch_blocks: Vec<PredictionBlock> = Vec::new();
let mut by_sample_target: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
let mut target_block_names: Option<Vec<String>> = None;
for branch_id in &node_plan.input_nodes {
let blocks = ctx.prediction_store.find(
Some(branch_id),
Some(&PredictionPartition::Validation),
Some(&fold_id),
);
if blocks.is_empty() {
continue;
}
if blocks.len() > 1 {
return Err(DagMlError::OofValidation(format!(
"fusion merge node `{}` found {} validation blocks for branch `{branch_id}` in fold `{fold_id}`: the run context mixes several variants — reassemble each variant in its own context (native SELECT does this)",
node_plan.node_id,
blocks.len()
)));
}
let block = blocks[0];
block.validate_shape()?;
for sample_id in &block.sample_ids {
if !expected.contains(sample_id) {
return Err(DagMlError::OofValidation(format!(
"fusion merge node `{}` branch `{branch_id}` emitted sample `{sample_id}` outside fold `{fold_id}` validation set",
node_plan.node_id
)));
}
}
branch_blocks.push(block.clone());
for record in &ctx.regression_target_records {
if &record.producer_node != branch_id
|| record.partition != PredictionPartition::Validation
|| record.fold_id.as_ref() != Some(&fold_id)
|| record.variant_id != variant_id
{
continue;
}
if target_block_names.is_none() && !record.block.target_names.is_empty() {
target_block_names = Some(record.block.target_names.clone());
}
for (unit_id, row) in record.block.unit_ids.iter().zip(&record.block.values) {
let PredictionUnitId::Sample(sample_id) = unit_id else {
continue;
};
by_sample_target.insert(sample_id.clone(), row.clone());
}
}
}
let fused = match reduction {
MergeReduction::Fusion => {
reduce_predictions_across_branches(&branch_blocks, None, &node_plan.node_id)?
}
MergeReduction::FusionProbaMean => {
reduce_proba_mean_across_branches(&branch_blocks, &node_plan.node_id)?
}
MergeReduction::Concat => unreachable!("concat is handled by reassemble_separation_merge"),
};
let covered: BTreeSet<&SampleId> = fused.sample_ids.iter().collect();
if covered != expected {
let missing: Vec<String> = expected
.difference(&covered)
.map(|sample| sample.to_string())
.collect();
return Err(DagMlError::OofValidation(format!(
"fusion merge node `{}` fused OOF does not cover fold `{fold_id}` validation set (missing {} sample(s): {})",
node_plan.node_id,
missing.len(),
missing.join(", ")
)));
}
let fused_by_sample: BTreeMap<&SampleId, &Vec<f64>> =
fused.sample_ids.iter().zip(&fused.values).collect();
let sample_ids: Vec<SampleId> = fold.validation_sample_ids.clone();
let values: Vec<Vec<f64>> = sample_ids
.iter()
.map(|sample_id| fused_by_sample[sample_id].clone())
.collect();
let target_names = fused.target_names.clone();
let regression_targets = reassemble_merge_targets(
&node_plan.node_id,
&sample_ids,
&mut by_sample_target,
target_block_names.unwrap_or_default(),
)?
.into_iter()
.collect();
let branch_inputs: BTreeSet<&NodeId> = node_plan.input_nodes.iter().collect();
let mut input_lineage: Vec<LineageId> = Vec::new();
for record in ctx.lineage.records() {
if branch_inputs.contains(&record.node_id)
&& record.phase == scope.phase
&& record.fold_id.as_ref() == Some(&fold_id)
&& record.variant_id == variant_id
{
input_lineage.push(record.record_id.clone());
}
}
let variant_suffix = variant_id
.as_ref()
.map(|variant| format!(":{variant}"))
.unwrap_or_default();
let merged = PredictionBlock {
prediction_id: Some(format!(
"merge:{}:{fold_id}{variant_suffix}",
node_plan.node_id
)),
producer_node: node_plan.node_id.clone(),
partition: PredictionPartition::Validation,
fold_id: Some(fold_id.clone()),
sample_ids,
values,
target_names,
};
merged.validate_shape()?;
let lineage = LineageRecord {
record_id: LineageId::new(format!(
"lineage:{}:{fold_id}{variant_suffix}",
node_plan.node_id
))?,
run_id: ctx.run_id.clone(),
node_id: node_plan.node_id.clone(),
phase: scope.phase,
controller_id: node_plan.controller_id.clone(),
controller_version: node_plan.controller_version.clone(),
variant_id,
fold_id: Some(fold_id),
branch_path: Vec::new(),
input_lineage,
artifact_refs: Vec::new(),
params_fingerprint: node_plan.params_fingerprint.clone(),
data_model_shape_fingerprint: None,
aggregation_policy_fingerprint: None,
seed: None,
unsafe_flags: BTreeSet::new(),
metrics: BTreeMap::new(),
};
Ok(Some(NodeResult {
node_id: node_plan.node_id.clone(),
outputs: BTreeMap::new(),
predictions: vec![merged],
observation_predictions: Vec::new(),
aggregated_predictions: Vec::new(),
explanations: Vec::new(),
shape_deltas: Vec::new(),
artifacts: Vec::new(),
artifact_handles: BTreeMap::new(),
fit_influence_diagnostics: Vec::new(),
regression_targets,
lineage,
}))
}
pub(crate) fn branch_view_from_node_metadata(
plan: &ExecutionPlan,
node_id: &NodeId,
) -> Result<Option<crate::data::BranchViewPlan>> {
let node = match plan
.graph_plan
.graph
.nodes
.iter()
.find(|node| &node.id == node_id)
{
Some(node) => node,
None => return Ok(None),
};
let Some(value) = node.metadata.get("dsl_branch_view_plan") else {
return Ok(None);
};
let plan: crate::data::BranchViewPlan =
serde_json::from_value(value.clone()).map_err(|error| {
DagMlError::RuntimeValidation(format!(
"node `{node_id}` carries malformed `dsl_branch_view_plan` metadata: {error}"
))
})?;
plan.validate()?;
Ok(Some(plan))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DataViewRole {
Fit,
NonFit,
}
pub(crate) fn data_view_for_partition(
binding: &DataBinding,
fold_set: Option<&FoldSet>,
scope: &PhaseScope,
partition: DataRequestPartition,
branch_view: Option<&crate::data::BranchViewPlan>,
role: DataViewRole,
excluded_samples: &BTreeSet<SampleId>,
) -> Result<DataProviderViewSpec> {
let fold = fold_for_scope(fold_set, scope.fold_id.as_ref())?;
let mut sample_ids = sample_ids_for_partition(partition, fold_set, fold);
if role == DataViewRole::Fit
&& !binding.view_policy.include_excluded
&& !excluded_samples.is_empty()
{
if let Some(ids) = sample_ids.as_mut() {
ids.retain(|sample_id| !excluded_samples.contains(sample_id));
}
}
if binding.view_policy.require_sample_ids
&& matches!(
partition,
DataRequestPartition::FoldTrain | DataRequestPartition::FoldValidation
)
&& scope.fold_id.is_some()
&& sample_ids.as_ref().is_none_or(Vec::is_empty)
{
return Err(DagMlError::RuntimeValidation(format!(
"data binding `{}` on `{}` requires sample ids for {:?}",
binding.input_name, binding.node_id, partition
)));
}
let include_augmented = match partition {
DataRequestPartition::FoldTrain | DataRequestPartition::FullTrain => {
binding.view_policy.include_augmented_train
}
DataRequestPartition::FoldValidation | DataRequestPartition::Predict => {
binding.view_policy.include_augmented_validation
}
};
let include_excluded = match role {
DataViewRole::Fit => binding.view_policy.include_excluded,
DataViewRole::NonFit => true,
};
let mut extra = BTreeMap::new();
extra.insert(
"feature_set_id".to_string(),
serde_json::Value::String(binding.feature_set_id().to_string()),
);
if let Some(source_index) = binding
.metadata
.get(crate::data::SOURCE_INDEX_METADATA_KEY)
.cloned()
{
extra.insert(
crate::data::SOURCE_INDEX_METADATA_KEY.to_string(),
source_index,
);
}
if !binding.view_policy.unsafe_flags.is_empty() {
extra.insert(
"unsafe_flags".to_string(),
serde_json::Value::Array(
binding
.view_policy
.unsafe_flags
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
),
);
}
let view = DataProviderViewSpec {
sample_ids,
partition,
fold_id: match partition {
DataRequestPartition::FoldTrain | DataRequestPartition::FoldValidation => {
scope.fold_id.clone()
}
DataRequestPartition::FullTrain | DataRequestPartition::Predict => None,
},
source_ids: source_ids_for_view(binding, branch_view)?,
columns: None,
include_augmented,
include_excluded,
branch_view: branch_view.cloned(),
extra,
};
view.validate()?;
Ok(view)
}
fn source_ids_for_view(
binding: &DataBinding,
branch_view: Option<&crate::data::BranchViewPlan>,
) -> Result<Option<Vec<String>>> {
let Some(branch_view) = branch_view else {
return Ok((!binding.source_ids.is_empty()).then(|| binding.source_ids.clone()));
};
if branch_view.mode != crate::data::BranchViewMode::BySource {
return Ok((!binding.source_ids.is_empty()).then(|| binding.source_ids.clone()));
}
if branch_view.selector.source_ids.len() != 1 {
return Err(DagMlError::RuntimeValidation(format!(
"by_source branch view `{}` must select exactly one source_id for `{}` on `{}`",
branch_view.view_id, binding.input_name, binding.node_id
)));
}
let source_id = branch_view.selector.source_ids[0].clone();
if !binding.source_ids.is_empty() && !binding.source_ids.iter().any(|item| item == &source_id) {
return Err(DagMlError::RuntimeValidation(format!(
"by_source branch view `{}` selects source `{source_id}` outside data binding source_ids {:?} for `{}` on `{}`",
branch_view.view_id, binding.source_ids, binding.input_name, binding.node_id
)));
}
Ok(Some(vec![source_id]))
}
pub(crate) fn data_partition_for_scope(
binding: &DataBinding,
scope: &PhaseScope,
) -> DataRequestPartition {
match scope.phase {
Phase::FitCv => binding.view_policy.fit_partition,
Phase::Refit => DataRequestPartition::FullTrain,
Phase::Predict | Phase::Explain if scope.fold_id.is_none() => DataRequestPartition::Predict,
Phase::Predict | Phase::Explain => binding.view_policy.predict_partition,
Phase::Compile | Phase::Plan | Phase::Select => DataRequestPartition::FullTrain,
}
}
pub(crate) fn fold_for_scope<'a>(
fold_set: Option<&'a FoldSet>,
fold_id: Option<&FoldId>,
) -> Result<Option<&'a FoldAssignment>> {
let Some(fold_id) = fold_id else {
return Ok(None);
};
let fold_set = fold_set.ok_or_else(|| {
DagMlError::RuntimeValidation(format!(
"fold `{fold_id}` requested but execution plan has no fold set"
))
})?;
fold_set
.folds
.iter()
.find(|fold| &fold.fold_id == fold_id)
.map(Some)
.ok_or_else(|| {
DagMlError::RuntimeValidation(format!(
"fold `{fold_id}` requested but is not present in fold set `{}`",
fold_set.id
))
})
}
pub(crate) fn inner_fold_set_for_scope(
campaign: &CampaignSpec,
outer_fold_set: Option<&FoldSet>,
node_plan: &NodePlan,
scope: &PhaseScope,
) -> Result<Option<FoldSet>> {
if scope.phase != Phase::FitCv {
return Ok(None);
}
let Some(spec) =
crate::fold::resolve_inner_cv(node_plan.inner_cv.as_ref(), campaign.inner_cv.as_ref())
else {
return Ok(None);
};
let Some(outer) = fold_for_scope(outer_fold_set, scope.fold_id.as_ref())? else {
return Ok(None);
};
let outer_groups = &outer_fold_set
.expect("fold_for_scope returned a fold, so the outer fold set is present")
.sample_groups;
Ok(Some(spec.build_inner_fold_set(outer, outer_groups)?))
}
pub(crate) fn sample_ids_for_partition(
partition: DataRequestPartition,
fold_set: Option<&FoldSet>,
fold: Option<&FoldAssignment>,
) -> Option<Vec<SampleId>> {
match partition {
DataRequestPartition::FoldTrain => fold.map(|fold| fold.train_sample_ids.clone()),
DataRequestPartition::FoldValidation => fold.map(|fold| fold.validation_sample_ids.clone()),
DataRequestPartition::FullTrain => fold_set.map(|fold_set| {
debug_assert!(
{
let in_a_fold: BTreeSet<&SampleId> = fold_set
.folds
.iter()
.flat_map(|fold| {
fold.train_sample_ids
.iter()
.chain(fold.validation_sample_ids.iter())
})
.collect();
fold_set
.sample_ids
.iter()
.all(|sample_id| in_a_fold.contains(sample_id))
},
"REFIT FullTrain universe must be fully fold-accounted (train∪validation); a sample outside every fold would be a held-out/test leakage into the refit training set"
);
fold_set.sample_ids.clone()
}),
DataRequestPartition::Predict => None,
}
}