Skip to main content

dag_ml_core/
aggregation.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{DagMlError, Result};
7use crate::ids::{ControllerId, FoldId, GroupId, NodeId, ObservationId, SampleId, TargetId};
8use crate::oof::{PredictionBlock, PredictionPartition};
9use crate::policy::{
10    AggregationMethod, AggregationPolicy, AggregationWeights, PredictionLevel, ReductionAxis,
11    ReductionMethod, ReductionPlan,
12};
13use crate::relation::{EntityUnitLevel, SampleRelationSet};
14
15pub const AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION: u32 = 1;
16pub const AGGREGATION_CONTROLLER_TASK_SCHEMA_ID: &str =
17    "https://github.com/GBeurier/dag-ml/schemas/aggregation_controller_task.v1.schema.json";
18pub const AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION: u32 = 1;
19pub const AGGREGATION_CONTROLLER_RESULT_SCHEMA_ID: &str =
20    "https://github.com/GBeurier/dag-ml/schemas/aggregation_controller_result.v1.schema.json";
21const DEFAULT_ROBUST_TRIM_FRACTION: f64 = 0.1;
22/// Default Hotelling-T2 cutoff (in units of the T2 statistic) used by the native
23/// `exclude_outliers` reducer when no explicit threshold is supplied. A repeated
24/// prediction whose squared Mahalanobis distance from the per-unit centroid
25/// exceeds this value is dropped before the surviving rows are averaged. The
26/// value is the 0.975 quantile of a chi-square distribution with one degree of
27/// freedom (~5.0239), a conventional single-target outlier gate.
28const DEFAULT_HOTELLING_T2_THRESHOLD: f64 = 5.023_886;
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct ObservationPredictionBlock {
33    #[serde(default)]
34    pub prediction_id: Option<String>,
35    pub producer_node: NodeId,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub producer_port: Option<String>,
38    pub partition: PredictionPartition,
39    pub fold_id: Option<FoldId>,
40    pub observation_ids: Vec<ObservationId>,
41    pub values: Vec<Vec<f64>>,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub weights: Vec<f64>,
44    #[serde(default)]
45    pub target_names: Vec<String>,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
49#[serde(
50    rename_all = "snake_case",
51    tag = "level",
52    content = "id",
53    deny_unknown_fields
54)]
55pub enum PredictionUnitId {
56    Sample(SampleId),
57    Target(TargetId),
58    Group(GroupId),
59}
60
61impl PredictionUnitId {
62    pub fn level(&self) -> PredictionLevel {
63        match self {
64            Self::Sample(_) => PredictionLevel::Sample,
65            Self::Target(_) => PredictionLevel::Target,
66            Self::Group(_) => PredictionLevel::Group,
67        }
68    }
69}
70
71impl fmt::Display for PredictionUnitId {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Self::Sample(id) => write!(f, "sample:{id}"),
75            Self::Target(id) => write!(f, "target:{id}"),
76            Self::Group(id) => write!(f, "group:{id}"),
77        }
78    }
79}
80
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct AggregatedPredictionBlock {
84    #[serde(default)]
85    pub prediction_id: Option<String>,
86    pub producer_node: NodeId,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub producer_port: Option<String>,
89    pub partition: PredictionPartition,
90    pub fold_id: Option<FoldId>,
91    pub level: PredictionLevel,
92    pub unit_ids: Vec<PredictionUnitId>,
93    pub values: Vec<Vec<f64>>,
94    #[serde(default)]
95    pub target_names: Vec<String>,
96}
97
98impl AggregatedPredictionBlock {
99    pub fn validate_shape(&self) -> Result<usize> {
100        if self.unit_ids.len() != self.values.len() {
101            return Err(DagMlError::OofValidation(format!(
102                "producer `{}` has {} aggregated unit ids but {} prediction rows",
103                self.producer_node,
104                self.unit_ids.len(),
105                self.values.len()
106            )));
107        }
108        if self
109            .unit_ids
110            .iter()
111            .any(|unit_id| unit_id.level() != self.level)
112        {
113            return Err(DagMlError::OofValidation(format!(
114                "producer `{}` emitted aggregated units outside level {:?}",
115                self.producer_node, self.level
116            )));
117        }
118        let unique = self.unit_ids.iter().collect::<BTreeSet<_>>();
119        if unique.len() != self.unit_ids.len() {
120            return Err(DagMlError::OofValidation(format!(
121                "producer `{}` emitted duplicate aggregated unit ids",
122                self.producer_node
123            )));
124        }
125        let width = self.values.first().map_or(0, Vec::len);
126        if width == 0 {
127            return Err(DagMlError::OofValidation(format!(
128                "producer `{}` emitted empty aggregated prediction rows",
129                self.producer_node
130            )));
131        }
132        if self.values.iter().any(|row| row.len() != width) {
133            return Err(DagMlError::OofValidation(format!(
134                "producer `{}` emitted ragged aggregated prediction rows",
135                self.producer_node
136            )));
137        }
138        if self.values.iter().flatten().any(|value| !value.is_finite()) {
139            return Err(DagMlError::OofValidation(format!(
140                "producer `{}` emitted non-finite aggregated prediction values",
141                self.producer_node
142            )));
143        }
144        if !self.target_names.is_empty() && self.target_names.len() != width {
145            return Err(DagMlError::OofValidation(format!(
146                "producer `{}` has {} aggregated target names for width {}",
147                self.producer_node,
148                self.target_names.len(),
149                width
150            )));
151        }
152        Ok(width)
153    }
154}
155
156impl ObservationPredictionBlock {
157    pub fn validate_shape(&self) -> Result<usize> {
158        if self.observation_ids.len() != self.values.len() {
159            return Err(DagMlError::OofValidation(format!(
160                "producer `{}` has {} observation ids but {} prediction rows",
161                self.producer_node,
162                self.observation_ids.len(),
163                self.values.len()
164            )));
165        }
166        let width = self.values.first().map_or(0, Vec::len);
167        if width == 0 {
168            return Err(DagMlError::OofValidation(format!(
169                "producer `{}` emitted empty observation prediction rows",
170                self.producer_node
171            )));
172        }
173        if self.values.iter().any(|row| row.len() != width) {
174            return Err(DagMlError::OofValidation(format!(
175                "producer `{}` emitted ragged observation prediction rows",
176                self.producer_node
177            )));
178        }
179        if self.values.iter().flatten().any(|value| !value.is_finite()) {
180            return Err(DagMlError::OofValidation(format!(
181                "producer `{}` emitted non-finite observation prediction values",
182                self.producer_node
183            )));
184        }
185        if !self.weights.is_empty() {
186            if self.weights.len() != self.observation_ids.len() {
187                return Err(DagMlError::OofValidation(format!(
188                    "producer `{}` has {} observation weights but {} observation ids",
189                    self.producer_node,
190                    self.weights.len(),
191                    self.observation_ids.len()
192                )));
193            }
194            if self
195                .weights
196                .iter()
197                .any(|weight| !weight.is_finite() || *weight < 0.0)
198            {
199                return Err(DagMlError::OofValidation(format!(
200                    "producer `{}` emitted non-finite or negative observation weights",
201                    self.producer_node
202                )));
203            }
204        }
205        if !self.target_names.is_empty() && self.target_names.len() != width {
206            return Err(DagMlError::OofValidation(format!(
207                "producer `{}` has {} target names for width {}",
208                self.producer_node,
209                self.target_names.len(),
210                width
211            )));
212        }
213        let unique = self.observation_ids.iter().collect::<BTreeSet<_>>();
214        if unique.len() != self.observation_ids.len() {
215            return Err(DagMlError::OofValidation(format!(
216                "producer `{}` emitted duplicate observation predictions",
217                self.producer_node
218            )));
219        }
220        Ok(width)
221    }
222}
223
224#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
225pub struct AggregationControllerTask {
226    #[serde(default = "default_aggregation_controller_task_schema_version")]
227    pub schema_version: u32,
228    pub task_id: String,
229    pub controller_id: ControllerId,
230    pub policy: AggregationPolicy,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub reduction_plan: Option<ReductionPlan>,
233    pub input: AggregationControllerInput,
234}
235
236#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
237#[serde(tag = "input_kind", rename_all = "snake_case")]
238pub enum AggregationControllerInput {
239    ObservationToSample {
240        block: ObservationPredictionBlock,
241        relations: SampleRelationSet,
242        requested_sample_order: Vec<SampleId>,
243    },
244    SampleToUnit {
245        block: PredictionBlock,
246        relations: SampleRelationSet,
247        requested_unit_order: Vec<PredictionUnitId>,
248    },
249}
250
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct AggregationControllerResult {
254    #[serde(default = "default_aggregation_controller_result_schema_version")]
255    pub schema_version: u32,
256    pub task_id: String,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub reduction_plan: Option<ReductionPlan>,
259    pub output: AggregationControllerOutput,
260}
261
262#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
263#[serde(tag = "output_kind", rename_all = "snake_case", deny_unknown_fields)]
264pub enum AggregationControllerOutput {
265    Sample { block: PredictionBlock },
266    Unit { block: AggregatedPredictionBlock },
267}
268
269impl AggregationControllerTask {
270    pub fn validate(&self) -> Result<()> {
271        if self.schema_version != AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION {
272            return Err(DagMlError::OofValidation(format!(
273                "aggregation controller task `{}` uses unsupported schema_version {}",
274                self.task_id, self.schema_version
275            )));
276        }
277        if self.task_id.trim().is_empty() {
278            return Err(DagMlError::OofValidation(
279                "aggregation controller task_id is empty".to_string(),
280            ));
281        }
282        self.policy.validate()?;
283        if self.policy.method != AggregationMethod::CustomController {
284            return Err(DagMlError::OofValidation(format!(
285                "aggregation controller task `{}` must use custom_controller method",
286                self.task_id
287            )));
288        }
289        let controller = self
290            .policy
291            .custom_controller
292            .as_ref()
293            .expect("custom_controller policy validation requires controller spec");
294        if controller.controller_id != self.controller_id {
295            return Err(DagMlError::OofValidation(format!(
296                "aggregation controller task `{}` targets controller `{}` but policy targets `{}`",
297                self.task_id, self.controller_id, controller.controller_id
298            )));
299        }
300        if let Some(reduction_plan) = &self.reduction_plan {
301            validate_aggregation_controller_reduction_plan(
302                reduction_plan,
303                &self.policy,
304                &self.input,
305            )?;
306        }
307        match &self.input {
308            AggregationControllerInput::ObservationToSample {
309                block,
310                relations,
311                requested_sample_order,
312            } => validate_aggregation_controller_observation_input(
313                block,
314                relations,
315                &self.policy,
316                requested_sample_order,
317            ),
318            AggregationControllerInput::SampleToUnit {
319                block,
320                relations,
321                requested_unit_order,
322            } => validate_aggregation_controller_sample_input(
323                block,
324                relations,
325                &self.policy,
326                requested_unit_order,
327            ),
328        }
329    }
330}
331
332impl AggregationControllerResult {
333    pub fn validate_for_task(&self, task: &AggregationControllerTask) -> Result<()> {
334        task.validate()?;
335        if self.schema_version != AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION {
336            return Err(DagMlError::OofValidation(format!(
337                "aggregation controller result `{}` uses unsupported schema_version {}",
338                self.task_id, self.schema_version
339            )));
340        }
341        if self.task_id != task.task_id {
342            return Err(DagMlError::OofValidation(format!(
343                "aggregation controller result task_id `{}` does not match task `{}`",
344                self.task_id, task.task_id
345            )));
346        }
347        validate_aggregation_controller_result_reduction_plan(task, self)?;
348        match (&task.input, &self.output) {
349            (
350                AggregationControllerInput::ObservationToSample {
351                    block: input_block,
352                    requested_sample_order,
353                    ..
354                },
355                AggregationControllerOutput::Sample { block },
356            ) => validate_aggregation_controller_sample_output(
357                input_block,
358                requested_sample_order,
359                block,
360            ),
361            (
362                AggregationControllerInput::SampleToUnit {
363                    block: input_block,
364                    requested_unit_order,
365                    ..
366                },
367                AggregationControllerOutput::Unit { block },
368            ) => validate_aggregation_controller_unit_output(
369                input_block,
370                requested_unit_order,
371                task.policy.aggregation_level,
372                block,
373            ),
374            (AggregationControllerInput::ObservationToSample { .. }, _) => {
375                Err(DagMlError::OofValidation(format!(
376                    "aggregation controller result `{}` must return sample output for observation input",
377                    self.task_id
378                )))
379            }
380            (AggregationControllerInput::SampleToUnit { .. }, _) => {
381                Err(DagMlError::OofValidation(format!(
382                    "aggregation controller result `{}` must return unit output for sample input",
383                    self.task_id
384                )))
385            }
386        }
387    }
388}
389
390fn validate_aggregation_controller_reduction_plan(
391    plan: &ReductionPlan,
392    policy: &AggregationPolicy,
393    input: &AggregationControllerInput,
394) -> Result<()> {
395    plan.validate()
396        .map_err(|error| DagMlError::OofValidation(error.to_string()))?;
397    if plan.method != ReductionMethod::from(policy.method) {
398        return Err(DagMlError::OofValidation(format!(
399            "reduction plan method {:?} does not match aggregation policy method {:?}",
400            plan.method, policy.method
401        )));
402    }
403    if plan.weight_source != policy.weights {
404        return Err(DagMlError::OofValidation(format!(
405            "reduction plan weight_source {:?} does not match aggregation policy weights {:?}",
406            plan.weight_source, policy.weights
407        )));
408    }
409    if plan.method == ReductionMethod::Custom {
410        let plan_controller = plan
411            .custom_controller
412            .as_ref()
413            .expect("reduction plan validation requires custom controller");
414        let policy_controller = policy
415            .custom_controller
416            .as_ref()
417            .expect("aggregation policy validation requires custom controller");
418        if plan_controller.controller_id != policy_controller.controller_id {
419            return Err(DagMlError::OofValidation(format!(
420                "reduction plan controller `{}` does not match aggregation policy controller `{}`",
421                plan_controller.controller_id, policy_controller.controller_id
422            )));
423        }
424    }
425    if plan.axis != ReductionAxis::Unit {
426        return Err(DagMlError::OofValidation(format!(
427            "aggregation controller reduction plan axis {:?} is not supported for unit aggregation tasks",
428            plan.axis
429        )));
430    }
431    match input {
432        AggregationControllerInput::ObservationToSample { .. } => {
433            if !matches!(
434                plan.input_unit_level,
435                EntityUnitLevel::Observation | EntityUnitLevel::Combo
436            ) {
437                return Err(DagMlError::OofValidation(format!(
438                    "observation aggregation reduction plan input_unit_level {:?} is invalid",
439                    plan.input_unit_level
440                )));
441            }
442            if plan.output_unit_level != EntityUnitLevel::PhysicalSample {
443                return Err(DagMlError::OofValidation(format!(
444                    "observation aggregation reduction plan output_unit_level {:?} must be physical_sample",
445                    plan.output_unit_level
446                )));
447            }
448            if policy.aggregation_level != PredictionLevel::Sample {
449                return Err(DagMlError::OofValidation(format!(
450                    "observation aggregation reduction plan must output sample predictions, got {:?}",
451                    policy.aggregation_level
452                )));
453            }
454        }
455        AggregationControllerInput::SampleToUnit { .. } => {
456            if plan.input_unit_level != EntityUnitLevel::PhysicalSample {
457                return Err(DagMlError::OofValidation(format!(
458                    "sample aggregation reduction plan input_unit_level {:?} must be physical_sample",
459                    plan.input_unit_level
460                )));
461            }
462            if plan.output_unit_level != EntityUnitLevel::PhysicalSample
463                || policy.aggregation_level != PredictionLevel::Sample
464            {
465                return Err(DagMlError::OofValidation(
466                    "sample aggregation reduction plans currently support only physical_sample output; target/group aggregation remains available without a ReductionPlan".to_string(),
467                ));
468            }
469        }
470    }
471    Ok(())
472}
473
474fn validate_aggregation_controller_result_reduction_plan(
475    task: &AggregationControllerTask,
476    result: &AggregationControllerResult,
477) -> Result<()> {
478    match (&task.reduction_plan, &result.reduction_plan) {
479        (Some(task_plan), Some(result_plan)) if task_plan == result_plan => Ok(()),
480        (Some(_), Some(_)) => Err(DagMlError::OofValidation(format!(
481            "aggregation controller result `{}` reduction_plan does not match task reduction_plan",
482            result.task_id
483        ))),
484        (Some(_), None) => Err(DagMlError::OofValidation(format!(
485            "aggregation controller result `{}` must echo task reduction_plan",
486            result.task_id
487        ))),
488        (None, Some(_)) => Err(DagMlError::OofValidation(format!(
489            "aggregation controller result `{}` declares reduction_plan but task does not",
490            result.task_id
491        ))),
492        (None, None) => Ok(()),
493    }
494}
495
496fn validate_aggregation_controller_observation_input(
497    block: &ObservationPredictionBlock,
498    relations: &SampleRelationSet,
499    policy: &AggregationPolicy,
500    requested_sample_order: &[SampleId],
501) -> Result<()> {
502    block.validate_shape()?;
503    relations.validate()?;
504    if policy.aggregation_level != PredictionLevel::Sample {
505        return Err(DagMlError::OofValidation(format!(
506            "observation aggregation controller task must output sample predictions, got {:?}",
507            policy.aggregation_level
508        )));
509    }
510    validate_unique_order(requested_sample_order, "requested_sample_order")?;
511    if matches!(
512        policy.weights,
513        AggregationWeights::ControllerEmitted | AggregationWeights::Quality
514    ) && block.weights.is_empty()
515    {
516        return Err(DagMlError::OofValidation(format!(
517            "aggregation controller task with {:?} weights requires observation weights",
518            policy.weights
519        )));
520    }
521    let requested = requested_sample_order.iter().collect::<BTreeSet<_>>();
522    let mut covered = BTreeSet::new();
523    for observation_id in &block.observation_ids {
524        let sample_id = relations
525            .sample_for_observation(observation_id)
526            .ok_or_else(|| {
527                DagMlError::OofValidation(format!(
528                    "observation prediction `{observation_id}` has no sample relation"
529                ))
530            })?;
531        if !requested.contains(sample_id) {
532            return Err(DagMlError::OofValidation(format!(
533                "observation prediction `{observation_id}` maps to unexpected sample `{sample_id}`"
534            )));
535        }
536        covered.insert(sample_id);
537    }
538    for sample_id in requested_sample_order {
539        if !covered.contains(sample_id) {
540            return Err(DagMlError::OofValidation(format!(
541                "sample `{sample_id}` has no observation predictions for aggregation controller task"
542            )));
543        }
544    }
545    Ok(())
546}
547
548fn validate_aggregation_controller_sample_input(
549    block: &PredictionBlock,
550    relations: &SampleRelationSet,
551    policy: &AggregationPolicy,
552    requested_unit_order: &[PredictionUnitId],
553) -> Result<()> {
554    validate_sample_prediction_block(block)?;
555    relations.validate()?;
556    if policy.aggregation_level == PredictionLevel::Observation {
557        return Err(DagMlError::OofValidation(
558            "sample aggregation controller task cannot output observation-level predictions"
559                .to_string(),
560        ));
561    }
562    if matches!(
563        policy.weights,
564        AggregationWeights::ControllerEmitted | AggregationWeights::Quality
565    ) {
566        return Err(DagMlError::OofValidation(format!(
567            "sample aggregation controller task cannot use {:?} weights without sample weights",
568            policy.weights
569        )));
570    }
571    validate_unique_order(requested_unit_order, "requested_unit_order")?;
572    if requested_unit_order
573        .iter()
574        .any(|unit_id| unit_id.level() != policy.aggregation_level)
575    {
576        return Err(DagMlError::OofValidation(format!(
577            "aggregation controller requested units do not match level {:?}",
578            policy.aggregation_level
579        )));
580    }
581    let requested = requested_unit_order.iter().collect::<BTreeSet<_>>();
582    let mut covered = BTreeSet::new();
583    for sample_id in &block.sample_ids {
584        let unit_id = unit_for_sample(relations, policy.aggregation_level, sample_id)?;
585        if !requested.contains(&unit_id) {
586            return Err(DagMlError::OofValidation(format!(
587                "sample prediction `{sample_id}` maps to unexpected aggregation unit `{unit_id}`"
588            )));
589        }
590        covered.insert(unit_id);
591    }
592    for unit_id in requested_unit_order {
593        if !covered.contains(unit_id) {
594            return Err(DagMlError::OofValidation(format!(
595                "aggregation unit `{unit_id}` has no sample predictions for aggregation controller task"
596            )));
597        }
598    }
599    Ok(())
600}
601
602fn validate_aggregation_controller_sample_output(
603    input_block: &ObservationPredictionBlock,
604    requested_sample_order: &[SampleId],
605    block: &PredictionBlock,
606) -> Result<()> {
607    validate_sample_prediction_block(block)?;
608    if block.producer_node != input_block.producer_node
609        || block.partition != input_block.partition
610        || block.fold_id != input_block.fold_id
611    {
612        return Err(DagMlError::OofValidation(format!(
613            "aggregation controller sample output for `{}` does not preserve producer, partition and fold",
614            input_block.producer_node
615        )));
616    }
617    if block.target_names != input_block.target_names {
618        return Err(DagMlError::OofValidation(format!(
619            "aggregation controller sample output for `{}` does not preserve target names",
620            input_block.producer_node
621        )));
622    }
623    if block.sample_ids != requested_sample_order {
624        return Err(DagMlError::OofValidation(format!(
625            "aggregation controller sample output for `{}` does not match requested sample order",
626            input_block.producer_node
627        )));
628    }
629    Ok(())
630}
631
632fn validate_aggregation_controller_unit_output(
633    input_block: &PredictionBlock,
634    requested_unit_order: &[PredictionUnitId],
635    expected_level: PredictionLevel,
636    block: &AggregatedPredictionBlock,
637) -> Result<()> {
638    block.validate_shape()?;
639    if block.producer_node != input_block.producer_node
640        || block.partition != input_block.partition
641        || block.fold_id != input_block.fold_id
642    {
643        return Err(DagMlError::OofValidation(format!(
644            "aggregation controller unit output for `{}` does not preserve producer, partition and fold",
645            input_block.producer_node
646        )));
647    }
648    if block.target_names != input_block.target_names {
649        return Err(DagMlError::OofValidation(format!(
650            "aggregation controller unit output for `{}` does not preserve target names",
651            input_block.producer_node
652        )));
653    }
654    if block.level != expected_level {
655        return Err(DagMlError::OofValidation(format!(
656            "aggregation controller unit output for `{}` has level {:?}, expected {:?}",
657            input_block.producer_node, block.level, expected_level
658        )));
659    }
660    if block.unit_ids != requested_unit_order {
661        return Err(DagMlError::OofValidation(format!(
662            "aggregation controller unit output for `{}` does not match requested unit order",
663            input_block.producer_node
664        )));
665    }
666    Ok(())
667}
668
669fn validate_unique_order<T>(values: &[T], label: &str) -> Result<()>
670where
671    T: Ord,
672{
673    if values.is_empty() {
674        return Err(DagMlError::OofValidation(format!(
675            "aggregation controller {label} is empty"
676        )));
677    }
678    let unique = values.iter().collect::<BTreeSet<_>>();
679    if unique.len() != values.len() {
680        return Err(DagMlError::OofValidation(format!(
681            "aggregation controller {label} contains duplicates"
682        )));
683    }
684    Ok(())
685}
686
687pub fn aggregate_observation_predictions(
688    block: &ObservationPredictionBlock,
689    relations: &SampleRelationSet,
690    policy: &AggregationPolicy,
691    requested_sample_order: &[SampleId],
692) -> Result<PredictionBlock> {
693    let width = block.validate_shape()?;
694    relations.validate()?;
695    policy.validate()?;
696    if requested_sample_order.is_empty() {
697        return Err(DagMlError::OofValidation(
698            "aggregation requested_sample_order is empty".to_string(),
699        ));
700    }
701    let requested = requested_sample_order.iter().collect::<BTreeSet<_>>();
702    if requested.len() != requested_sample_order.len() {
703        return Err(DagMlError::OofValidation(
704            "aggregation requested_sample_order contains duplicates".to_string(),
705        ));
706    }
707    if policy.aggregation_level != PredictionLevel::Sample {
708        return Err(DagMlError::OofValidation(format!(
709            "observation aggregation currently supports sample-level output, got {:?}",
710            policy.aggregation_level
711        )));
712    }
713    if policy.method == AggregationMethod::WeightedMean
714        && policy.weights == AggregationWeights::None
715    {
716        return Err(DagMlError::OofValidation(
717            "weighted_mean aggregation requires an explicit weights policy".to_string(),
718        ));
719    }
720    if policy.method != AggregationMethod::WeightedMean
721        && policy.weights != AggregationWeights::None
722    {
723        return Err(DagMlError::OofValidation(format!(
724            "aggregation weights {:?} are only valid with weighted_mean",
725            policy.weights
726        )));
727    }
728    if !block.weights.is_empty() && policy.method != AggregationMethod::WeightedMean {
729        return Err(DagMlError::OofValidation(format!(
730            "producer `{}` supplied observation weights for non-weighted aggregation {:?}",
731            block.producer_node, policy.method
732        )));
733    }
734
735    let store_rows = matches!(
736        policy.method,
737        AggregationMethod::Median
738            | AggregationMethod::Vote
739            | AggregationMethod::RobustMean
740            | AggregationMethod::ExcludeOutliers
741    );
742    let mut accumulators = requested_sample_order
743        .iter()
744        .cloned()
745        .map(|sample_id| (sample_id, SampleAccumulator::new(width, store_rows)))
746        .collect::<BTreeMap<_, _>>();
747
748    for (row_idx, (observation_id, row)) in block
749        .observation_ids
750        .iter()
751        .zip(block.values.iter())
752        .enumerate()
753    {
754        let sample_id = relations
755            .sample_for_observation(observation_id)
756            .ok_or_else(|| {
757                DagMlError::OofValidation(format!(
758                    "observation prediction `{observation_id}` has no sample relation"
759                ))
760            })?;
761        if !requested.contains(sample_id) {
762            return Err(DagMlError::OofValidation(format!(
763                "observation prediction `{observation_id}` maps to unexpected sample `{sample_id}`"
764            )));
765        }
766        let accumulator = accumulators
767            .get_mut(sample_id)
768            .expect("requested sample accumulator exists");
769        let weight = observation_weight(block, policy, row_idx)?;
770        accumulator.push(row, weight);
771    }
772
773    let values = requested_sample_order
774        .iter()
775        .map(|sample_id| {
776            let accumulator = accumulators
777                .get(sample_id)
778                .expect("requested sample accumulator exists");
779            if accumulator.count == 0 {
780                return Err(DagMlError::OofValidation(format!(
781                    "sample `{sample_id}` has no observation predictions to aggregate"
782                )));
783            }
784            match policy.method {
785                AggregationMethod::Mean => Ok(accumulator.mean()),
786                AggregationMethod::WeightedMean => accumulator.weighted_mean(&sample_id.to_string()),
787                AggregationMethod::Median => Ok(accumulator.median()),
788                AggregationMethod::Vote => Ok(accumulator.vote()),
789                AggregationMethod::RobustMean => {
790                    Ok(accumulator.robust_mean(DEFAULT_ROBUST_TRIM_FRACTION))
791                }
792                AggregationMethod::ExcludeOutliers => {
793                    accumulator.hotelling_t2_exclude_mean(DEFAULT_HOTELLING_T2_THRESHOLD)
794                }
795                AggregationMethod::None => {
796                    if accumulator.count == 1 {
797                        Ok(accumulator
798                            .first_row
799                            .clone()
800                            .expect("single prediction accumulator stores first row"))
801                    } else {
802                        Err(DagMlError::OofValidation(format!(
803                            "sample `{sample_id}` has {} observation predictions but aggregation method is none",
804                            accumulator.count
805                        )))
806                    }
807                }
808                AggregationMethod::CustomController => Err(DagMlError::OofValidation(format!(
809                    "aggregation method {:?} is delegated to an aggregation controller",
810                    policy.method
811                ))),
812            }
813        })
814        .collect::<Result<Vec<Vec<f64>>>>()?;
815
816    Ok(PredictionBlock {
817        prediction_id: block
818            .prediction_id
819            .as_ref()
820            .map(|prediction_id| format!("{prediction_id}:sample_agg")),
821        producer_node: block.producer_node.clone(),
822        producer_port: block.producer_port.clone(),
823        partition: block.partition.clone(),
824        fold_id: block.fold_id.clone(),
825        sample_ids: requested_sample_order.to_vec(),
826        values,
827        target_names: block.target_names.clone(),
828    })
829}
830
831pub fn aggregate_sample_predictions_by_unit(
832    block: &PredictionBlock,
833    relations: &SampleRelationSet,
834    policy: &AggregationPolicy,
835    requested_unit_order: &[PredictionUnitId],
836) -> Result<AggregatedPredictionBlock> {
837    let width = validate_sample_prediction_block(block)?;
838    relations.validate()?;
839    policy.validate()?;
840    if requested_unit_order.is_empty() {
841        return Err(DagMlError::OofValidation(
842            "aggregation requested_unit_order is empty".to_string(),
843        ));
844    }
845    let requested_level = policy.aggregation_level;
846    if requested_level == PredictionLevel::Observation {
847        return Err(DagMlError::OofValidation(
848            "sample prediction aggregation cannot output observation-level predictions".to_string(),
849        ));
850    }
851    if requested_unit_order
852        .iter()
853        .any(|unit_id| unit_id.level() != requested_level)
854    {
855        return Err(DagMlError::OofValidation(format!(
856            "aggregation requested units do not match level {:?}",
857            requested_level
858        )));
859    }
860    let requested = requested_unit_order.iter().collect::<BTreeSet<_>>();
861    if requested.len() != requested_unit_order.len() {
862        return Err(DagMlError::OofValidation(
863            "aggregation requested_unit_order contains duplicates".to_string(),
864        ));
865    }
866
867    let by_sample = block
868        .sample_ids
869        .iter()
870        .cloned()
871        .zip(block.values.iter().cloned())
872        .collect::<BTreeMap<_, _>>();
873    if requested_level == PredictionLevel::Sample {
874        let values = requested_unit_order
875            .iter()
876            .map(|unit_id| {
877                let PredictionUnitId::Sample(sample_id) = unit_id else {
878                    unreachable!("requested unit level already validated");
879                };
880                by_sample.get(sample_id).cloned().ok_or_else(|| {
881                    DagMlError::OofValidation(format!(
882                        "sample prediction block for `{}` is missing requested sample `{sample_id}`",
883                        block.producer_node
884                    ))
885                })
886            })
887            .collect::<Result<Vec<_>>>()?;
888        if by_sample.len() != requested_unit_order.len() {
889            return Err(DagMlError::OofValidation(format!(
890                "sample prediction block for `{}` contains samples outside requested sample order",
891                block.producer_node
892            )));
893        }
894        let aggregated = AggregatedPredictionBlock {
895            prediction_id: block.prediction_id.clone(),
896            producer_node: block.producer_node.clone(),
897            producer_port: None,
898            partition: block.partition.clone(),
899            fold_id: block.fold_id.clone(),
900            level: PredictionLevel::Sample,
901            unit_ids: requested_unit_order.to_vec(),
902            values,
903            target_names: block.target_names.clone(),
904        };
905        aggregated.validate_shape()?;
906        return Ok(aggregated);
907    }
908
909    if policy.method == AggregationMethod::WeightedMean
910        && matches!(
911            policy.weights,
912            AggregationWeights::ControllerEmitted | AggregationWeights::Quality
913        )
914    {
915        return Err(DagMlError::OofValidation(format!(
916            "sample-to-{:?} weighted_mean cannot use {:?} weights without sample-level weights",
917            requested_level, policy.weights
918        )));
919    }
920
921    let store_rows = matches!(
922        policy.method,
923        AggregationMethod::Median
924            | AggregationMethod::Vote
925            | AggregationMethod::RobustMean
926            | AggregationMethod::ExcludeOutliers
927    );
928    let mut accumulators = requested_unit_order
929        .iter()
930        .cloned()
931        .map(|unit_id| (unit_id, SampleAccumulator::new(width, store_rows)))
932        .collect::<BTreeMap<_, _>>();
933
934    for (sample_id, row) in block.sample_ids.iter().zip(block.values.iter()) {
935        let unit_id = unit_for_sample(relations, requested_level, sample_id)?;
936        if !requested.contains(&unit_id) {
937            return Err(DagMlError::OofValidation(format!(
938                "sample prediction `{sample_id}` maps to unexpected aggregation unit `{unit_id}`"
939            )));
940        }
941        let weight = sample_weight(relations, policy, sample_id)?;
942        accumulators
943            .get_mut(&unit_id)
944            .expect("requested aggregation unit accumulator exists")
945            .push(row, weight);
946    }
947
948    let values = requested_unit_order
949        .iter()
950        .map(|unit_id| {
951            let accumulator = accumulators
952                .get(unit_id)
953                .expect("requested aggregation unit accumulator exists");
954            if accumulator.count == 0 {
955                return Err(DagMlError::OofValidation(format!(
956                    "aggregation unit `{unit_id}` has no sample predictions to aggregate"
957                )));
958            }
959            match policy.method {
960                AggregationMethod::Mean => Ok(accumulator.mean()),
961                AggregationMethod::WeightedMean => accumulator.weighted_mean(&unit_id.to_string()),
962                AggregationMethod::Median => Ok(accumulator.median()),
963                AggregationMethod::Vote => Ok(accumulator.vote()),
964                AggregationMethod::RobustMean => {
965                    Ok(accumulator.robust_mean(DEFAULT_ROBUST_TRIM_FRACTION))
966                }
967                AggregationMethod::ExcludeOutliers => {
968                    accumulator.hotelling_t2_exclude_mean(DEFAULT_HOTELLING_T2_THRESHOLD)
969                }
970                AggregationMethod::None => {
971                    if accumulator.count == 1 {
972                        Ok(accumulator
973                            .first_row
974                            .clone()
975                            .expect("single prediction accumulator stores first row"))
976                    } else {
977                        Err(DagMlError::OofValidation(format!(
978                            "aggregation unit `{unit_id}` has {} sample predictions but aggregation method is none",
979                            accumulator.count
980                        )))
981                    }
982                }
983                AggregationMethod::CustomController => Err(DagMlError::OofValidation(format!(
984                    "aggregation method {:?} is delegated to an aggregation controller",
985                    policy.method
986                ))),
987            }
988        })
989        .collect::<Result<Vec<_>>>()?;
990
991    let suffix = match requested_level {
992        PredictionLevel::Target => "target_agg",
993        PredictionLevel::Group => "group_agg",
994        PredictionLevel::Sample => "sample_agg",
995        PredictionLevel::Observation => unreachable!("observation output rejected above"),
996    };
997    let aggregated = AggregatedPredictionBlock {
998        prediction_id: block
999            .prediction_id
1000            .as_ref()
1001            .map(|prediction_id| format!("{prediction_id}:{suffix}")),
1002        producer_node: block.producer_node.clone(),
1003        producer_port: None,
1004        partition: block.partition.clone(),
1005        fold_id: block.fold_id.clone(),
1006        level: requested_level,
1007        unit_ids: requested_unit_order.to_vec(),
1008        values,
1009        target_names: block.target_names.clone(),
1010    };
1011    aggregated.validate_shape()?;
1012    Ok(aggregated)
1013}
1014
1015fn validate_sample_prediction_block(block: &PredictionBlock) -> Result<usize> {
1016    block.validate_content()
1017}
1018
1019fn unit_for_sample(
1020    relations: &SampleRelationSet,
1021    level: PredictionLevel,
1022    sample_id: &SampleId,
1023) -> Result<PredictionUnitId> {
1024    match level {
1025        PredictionLevel::Sample => Ok(PredictionUnitId::Sample(sample_id.clone())),
1026        PredictionLevel::Target => relations
1027            .target_for_sample(sample_id)
1028            .cloned()
1029            .map(PredictionUnitId::Target)
1030            .ok_or_else(|| {
1031                DagMlError::OofValidation(format!(
1032                    "sample `{sample_id}` is missing target id for target aggregation"
1033                ))
1034            }),
1035        PredictionLevel::Group => relations
1036            .group_for_sample(sample_id)
1037            .cloned()
1038            .map(PredictionUnitId::Group)
1039            .ok_or_else(|| {
1040                DagMlError::OofValidation(format!(
1041                    "sample `{sample_id}` is missing group id for group aggregation"
1042                ))
1043            }),
1044        PredictionLevel::Observation => Err(DagMlError::OofValidation(
1045            "sample prediction aggregation cannot output observation-level predictions".to_string(),
1046        )),
1047    }
1048}
1049
1050fn sample_weight(
1051    relations: &SampleRelationSet,
1052    policy: &AggregationPolicy,
1053    sample_id: &SampleId,
1054) -> Result<f64> {
1055    if policy.method != AggregationMethod::WeightedMean {
1056        return Ok(1.0);
1057    }
1058    match policy.weights {
1059        AggregationWeights::RepetitionCount => {
1060            let count = relations.observation_count_for_sample(sample_id);
1061            if count == 0 {
1062                return Err(DagMlError::OofValidation(format!(
1063                    "sample `{sample_id}` has no observation relations for repetition_count weights"
1064                )));
1065            }
1066            Ok(count as f64)
1067        }
1068        AggregationWeights::ControllerEmitted | AggregationWeights::Quality => {
1069            Err(DagMlError::OofValidation(format!(
1070                "sample-level {:?} weights are not present in PredictionBlock",
1071                policy.weights
1072            )))
1073        }
1074        AggregationWeights::None => Err(DagMlError::OofValidation(
1075            "weighted_mean aggregation requires an explicit weights policy".to_string(),
1076        )),
1077    }
1078}
1079
1080#[derive(Clone, Debug)]
1081struct SampleAccumulator {
1082    sum: Vec<f64>,
1083    weighted_sum: Vec<f64>,
1084    weight_sum: f64,
1085    rows: Vec<Vec<f64>>,
1086    first_row: Option<Vec<f64>>,
1087    store_rows: bool,
1088    count: usize,
1089}
1090
1091impl SampleAccumulator {
1092    fn new(width: usize, store_rows: bool) -> Self {
1093        Self {
1094            sum: vec![0.0; width],
1095            weighted_sum: vec![0.0; width],
1096            weight_sum: 0.0,
1097            rows: Vec::new(),
1098            first_row: None,
1099            store_rows,
1100            count: 0,
1101        }
1102    }
1103
1104    fn push(&mut self, row: &[f64], weight: f64) {
1105        for (idx, value) in row.iter().enumerate() {
1106            self.sum[idx] += *value;
1107            self.weighted_sum[idx] += *value * weight;
1108        }
1109        self.weight_sum += weight;
1110        if self.first_row.is_none() {
1111            self.first_row = Some(row.to_vec());
1112        }
1113        if self.store_rows {
1114            self.rows.push(row.to_vec());
1115        }
1116        self.count += 1;
1117    }
1118
1119    fn mean(&self) -> Vec<f64> {
1120        self.sum
1121            .iter()
1122            .map(|value| *value / self.count as f64)
1123            .collect()
1124    }
1125
1126    fn weighted_mean(&self, unit_label: &str) -> Result<Vec<f64>> {
1127        if self.weight_sum <= 0.0 {
1128            return Err(DagMlError::OofValidation(format!(
1129                "aggregation unit `{unit_label}` has zero total prediction weight"
1130            )));
1131        }
1132        Ok(self
1133            .weighted_sum
1134            .iter()
1135            .map(|value| *value / self.weight_sum)
1136            .collect())
1137    }
1138
1139    fn median(&self) -> Vec<f64> {
1140        let width = self.sum.len();
1141        (0..width)
1142            .map(|column_idx| {
1143                let mut column = self
1144                    .rows
1145                    .iter()
1146                    .map(|row| row[column_idx])
1147                    .collect::<Vec<_>>();
1148                column.sort_by(f64::total_cmp);
1149                let middle = column.len() / 2;
1150                if column.len() % 2 == 1 {
1151                    column[middle]
1152                } else {
1153                    (column[middle - 1] + column[middle]) / 2.0
1154                }
1155            })
1156            .collect()
1157    }
1158
1159    fn vote(&self) -> Vec<f64> {
1160        let width = self.sum.len();
1161        (0..width)
1162            .map(|column_idx| {
1163                let mut column = self
1164                    .rows
1165                    .iter()
1166                    .map(|row| row[column_idx])
1167                    .collect::<Vec<_>>();
1168                column.sort_by(f64::total_cmp);
1169                mode_sorted(&column)
1170            })
1171            .collect()
1172    }
1173
1174    fn robust_mean(&self, trim_fraction: f64) -> Vec<f64> {
1175        let width = self.sum.len();
1176        (0..width)
1177            .map(|column_idx| {
1178                let mut column = self
1179                    .rows
1180                    .iter()
1181                    .map(|row| row[column_idx])
1182                    .collect::<Vec<_>>();
1183                column.sort_by(f64::total_cmp);
1184                let trim_count = ((column.len() as f64) * trim_fraction).floor() as usize;
1185                let max_trim = column.len().saturating_sub(1) / 2;
1186                let trim_count = trim_count.min(max_trim);
1187                let kept = &column[trim_count..column.len() - trim_count];
1188                kept.iter().sum::<f64>() / kept.len() as f64
1189            })
1190            .collect()
1191    }
1192
1193    /// Hotelling-T2 robust mean: drop each stored row whose squared Mahalanobis
1194    /// distance from the per-unit centroid exceeds `threshold`, then average the
1195    /// survivors. To avoid an outlier masking itself by inflating the spread it
1196    /// is measured against, each row's T2 is computed leave-one-out: against the
1197    /// centroid and (population) variance of every *other* row —
1198    /// `T2_i = sum_c (x_ic - mean_{-i,c})^2 / var_{-i,c}`. A column whose
1199    /// leave-one-out variance is (near) zero contributes nothing, so a constant
1200    /// target never spuriously flags rows. With fewer than three rows there is
1201    /// no robust spread to gate on, so all rows are kept (mean-of-rows). If
1202    /// every row is flagged, the unfiltered mean is returned rather than
1203    /// producing an empty aggregate.
1204    fn hotelling_t2_exclude_mean(&self, threshold: f64) -> Result<Vec<f64>> {
1205        let width = self.sum.len();
1206        if self.count == 0 {
1207            return Err(DagMlError::OofValidation(
1208                "exclude_outliers aggregation requires at least one prediction".to_string(),
1209            ));
1210        }
1211        let plain_mean = self.mean();
1212        if self.count < 3 {
1213            return Ok(plain_mean);
1214        }
1215        let others = (self.count - 1) as f64;
1216        let kept = self
1217            .rows
1218            .iter()
1219            .enumerate()
1220            .filter(|(skip_idx, _)| {
1221                let t2 = (0..width)
1222                    .map(|column_idx| {
1223                        let mean_others =
1224                            (self.sum[column_idx] - self.rows[*skip_idx][column_idx]) / others;
1225                        let var_others = self
1226                            .rows
1227                            .iter()
1228                            .enumerate()
1229                            .filter(|(other_idx, _)| other_idx != skip_idx)
1230                            .map(|(_, other)| {
1231                                let delta = other[column_idx] - mean_others;
1232                                delta * delta
1233                            })
1234                            .sum::<f64>()
1235                            / others;
1236                        if var_others <= f64::EPSILON {
1237                            0.0
1238                        } else {
1239                            let delta = self.rows[*skip_idx][column_idx] - mean_others;
1240                            delta * delta / var_others
1241                        }
1242                    })
1243                    .sum::<f64>();
1244                t2 <= threshold
1245            })
1246            .map(|(_, row)| row)
1247            .collect::<Vec<_>>();
1248        if kept.is_empty() {
1249            return Ok(plain_mean);
1250        }
1251        let kept_count = kept.len() as f64;
1252        Ok((0..width)
1253            .map(|column_idx| kept.iter().map(|row| row[column_idx]).sum::<f64>() / kept_count)
1254            .collect())
1255    }
1256}
1257
1258fn observation_weight(
1259    block: &ObservationPredictionBlock,
1260    policy: &AggregationPolicy,
1261    row_idx: usize,
1262) -> Result<f64> {
1263    if policy.method != AggregationMethod::WeightedMean {
1264        return Ok(1.0);
1265    }
1266    match policy.weights {
1267        AggregationWeights::ControllerEmitted | AggregationWeights::Quality => block
1268            .weights
1269            .get(row_idx)
1270            .copied()
1271            .ok_or_else(|| {
1272                DagMlError::OofValidation(format!(
1273                    "weighted_mean aggregation with {:?} weights requires one weight per observation",
1274                    policy.weights
1275                ))
1276            }),
1277        AggregationWeights::RepetitionCount => Ok(1.0),
1278        AggregationWeights::None => Err(DagMlError::OofValidation(
1279            "weighted_mean aggregation requires an explicit weights policy".to_string(),
1280        )),
1281    }
1282}
1283
1284fn mode_sorted(values: &[f64]) -> f64 {
1285    let mut best_value = values[0];
1286    let mut best_count = 1usize;
1287    let mut current_value = values[0];
1288    let mut current_count = 1usize;
1289    for value in values.iter().skip(1) {
1290        if *value == current_value {
1291            current_count += 1;
1292            continue;
1293        }
1294        if current_count > best_count {
1295            best_value = current_value;
1296            best_count = current_count;
1297        }
1298        current_value = *value;
1299        current_count = 1;
1300    }
1301    if current_count > best_count {
1302        current_value
1303    } else {
1304        best_value
1305    }
1306}
1307
1308fn default_aggregation_controller_task_schema_version() -> u32 {
1309    AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION
1310}
1311
1312fn default_aggregation_controller_result_schema_version() -> u32 {
1313    AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION
1314}
1315
1316/// Reduce per-fold prediction blocks (same producer + partition) into one block, keyed by
1317/// `sample_id` — the native cross-fold ensemble dag-ml previously lacked. A sample that appears in
1318/// exactly one fold (the disjoint OOF/validation case) passes through unchanged (mean-of-one); a
1319/// sample predicted by several folds (the shared train/test case) is (weighted) averaged. `avg` =
1320/// no weights; `w_avg` = per-fold weights (e.g. `1/shifted_val_rmse`). First-seen sample order is
1321/// preserved; the join is identity-keyed, never positional.
1322pub fn reduce_predictions_across_folds(
1323    blocks: &[PredictionBlock],
1324    weights: Option<&[f64]>,
1325    fold_label: &str,
1326) -> Result<PredictionBlock> {
1327    let first = blocks.first().ok_or_else(|| {
1328        DagMlError::OofValidation("cross-fold reduction needs at least one block".to_string())
1329    })?;
1330    if let Some(weights) = weights {
1331        if weights.len() != blocks.len() {
1332            return Err(DagMlError::OofValidation(format!(
1333                "cross-fold weights ({}) must match block count ({})",
1334                weights.len(),
1335                blocks.len()
1336            )));
1337        }
1338    }
1339    let width = first.values.first().map_or(0, Vec::len);
1340    if width == 0 {
1341        return Err(DagMlError::OofValidation(
1342            "cross-fold reduction: first block has empty prediction rows".to_string(),
1343        ));
1344    }
1345    let mut order: Vec<SampleId> = Vec::new();
1346    let mut index: BTreeMap<SampleId, usize> = BTreeMap::new();
1347    let mut weighted_sums: Vec<Vec<f64>> = Vec::new();
1348    let mut weight_totals: Vec<f64> = Vec::new();
1349    for (position, block) in blocks.iter().enumerate() {
1350        if block.producer_node != first.producer_node || block.partition != first.partition {
1351            return Err(DagMlError::OofValidation(
1352                "cross-fold reduction: blocks differ in producer or partition".to_string(),
1353            ));
1354        }
1355        // Mandatory content gate: finite values + no within-block (within-fold) duplicate
1356        // sample. Accumulation below is per (sample, fold); a within-fold duplicate would be
1357        // averaged in twice (a double-count) and a NaN/Inf would poison the fused mean.
1358        block.validate_content()?;
1359        let weight = weights.map_or(1.0, |weights| weights[position]);
1360        if !weight.is_finite() || weight < 0.0 {
1361            return Err(DagMlError::OofValidation(
1362                "cross-fold reduction: weights must be finite and non-negative".to_string(),
1363            ));
1364        }
1365        for (sample_id, row) in block.sample_ids.iter().zip(&block.values) {
1366            if row.len() != width {
1367                return Err(DagMlError::OofValidation(
1368                    "cross-fold reduction: ragged prediction width".to_string(),
1369                ));
1370            }
1371            let slot = *index.entry(sample_id.clone()).or_insert_with(|| {
1372                order.push(sample_id.clone());
1373                weighted_sums.push(vec![0.0; width]);
1374                weight_totals.push(0.0);
1375                order.len() - 1
1376            });
1377            for (acc, value) in weighted_sums[slot].iter_mut().zip(row) {
1378                *acc += value * weight;
1379            }
1380            weight_totals[slot] += weight;
1381        }
1382    }
1383    let mut values = Vec::with_capacity(order.len());
1384    for slot in 0..order.len() {
1385        let total = weight_totals[slot];
1386        if total <= 0.0 {
1387            return Err(DagMlError::OofValidation(
1388                "cross-fold reduction: a sample had zero total weight".to_string(),
1389            ));
1390        }
1391        values.push(weighted_sums[slot].iter().map(|sum| sum / total).collect());
1392    }
1393    Ok(PredictionBlock {
1394        prediction_id: None,
1395        producer_node: first.producer_node.clone(),
1396        producer_port: first.producer_port.clone(),
1397        partition: first.partition.clone(),
1398        fold_id: Some(FoldId::new(fold_label)?),
1399        sample_ids: order,
1400        values,
1401        target_names: first.target_names.clone(),
1402    })
1403}
1404
1405/// Reduce one prediction block per branch (each from a *different* producer/model) into a single
1406/// fused block under `merge_node`, keyed by `sample_id`. This is the cross-*branch* analogue of
1407/// [`reduce_predictions_across_folds`]: where that joins folds of one producer, this joins branches
1408/// of one merge point. Asymmetric-branch fusion is intrinsic — a *modelless* branch simply emits no
1409/// `PredictionBlock`, so it is absent from `branch_blocks` and contributes nothing. A sample is
1410/// averaged over exactly the model-bearing branches that predicted it (its branch coverage), never
1411/// over a fixed denominator, so partial coverage does not silently down-weight a sample. The join is
1412/// identity-keyed and the union sample order is first-seen; positional joins are never used.
1413///
1414/// `weights` (when present) are per-branch ensemble weights aligned to `branch_blocks` and applied
1415/// only to the branches that cover each sample. All blocks must share `partition`, prediction width
1416/// and `target_names`; differing producers are expected and preserved only through `merge_node`.
1417pub fn reduce_predictions_across_branches(
1418    branch_blocks: &[PredictionBlock],
1419    weights: Option<&[f64]>,
1420    merge_node: &NodeId,
1421) -> Result<PredictionBlock> {
1422    let first = branch_blocks.first().ok_or_else(|| {
1423        DagMlError::OofValidation(
1424            "cross-branch reduction needs at least one model-bearing branch".to_string(),
1425        )
1426    })?;
1427    if let Some(weights) = weights {
1428        if weights.len() != branch_blocks.len() {
1429            return Err(DagMlError::OofValidation(format!(
1430                "cross-branch weights ({}) must match model-bearing branch count ({})",
1431                weights.len(),
1432                branch_blocks.len()
1433            )));
1434        }
1435    }
1436    let width = first.validate_shape()?;
1437    let mut order: Vec<SampleId> = Vec::new();
1438    let mut index: BTreeMap<SampleId, usize> = BTreeMap::new();
1439    let mut weighted_sums: Vec<Vec<f64>> = Vec::new();
1440    let mut weight_totals: Vec<f64> = Vec::new();
1441    for (position, block) in branch_blocks.iter().enumerate() {
1442        if block.partition != first.partition {
1443            return Err(DagMlError::OofValidation(
1444                "cross-branch reduction: branches differ in partition".to_string(),
1445            ));
1446        }
1447        if block.target_names != first.target_names {
1448            return Err(DagMlError::OofValidation(
1449                "cross-branch reduction: branches differ in target names".to_string(),
1450            ));
1451        }
1452        // Mandatory content gate: finite values + no within-branch duplicate sample. Fusion
1453        // accumulates per (sample, branch), so a within-branch duplicate would be averaged in
1454        // twice (a double-count) and skew the mean — the cross-branch analogue of concat's
1455        // overlap rejection — and a NaN/Inf would poison the fused mean.
1456        block.validate_content()?;
1457        let weight = weights.map_or(1.0, |weights| weights[position]);
1458        if !weight.is_finite() || weight < 0.0 {
1459            return Err(DagMlError::OofValidation(
1460                "cross-branch reduction: weights must be finite and non-negative".to_string(),
1461            ));
1462        }
1463        for (sample_id, row) in block.sample_ids.iter().zip(&block.values) {
1464            if row.len() != width {
1465                return Err(DagMlError::OofValidation(
1466                    "cross-branch reduction: branches differ in prediction width".to_string(),
1467                ));
1468            }
1469            let slot = *index.entry(sample_id.clone()).or_insert_with(|| {
1470                order.push(sample_id.clone());
1471                weighted_sums.push(vec![0.0; width]);
1472                weight_totals.push(0.0);
1473                order.len() - 1
1474            });
1475            for (acc, value) in weighted_sums[slot].iter_mut().zip(row) {
1476                *acc += value * weight;
1477            }
1478            weight_totals[slot] += weight;
1479        }
1480    }
1481    let mut values = Vec::with_capacity(order.len());
1482    for slot in 0..order.len() {
1483        let total = weight_totals[slot];
1484        if total <= 0.0 {
1485            return Err(DagMlError::OofValidation(
1486                "cross-branch reduction: a sample had zero total branch weight".to_string(),
1487            ));
1488        }
1489        values.push(weighted_sums[slot].iter().map(|sum| sum / total).collect());
1490    }
1491    Ok(PredictionBlock {
1492        prediction_id: None,
1493        producer_node: merge_node.clone(),
1494        producer_port: None,
1495        partition: first.partition.clone(),
1496        fold_id: first.fold_id.clone(),
1497        sample_ids: order,
1498        values,
1499        target_names: first.target_names.clone(),
1500    })
1501}
1502
1503/// Probability-mean fusion for classification: average per-class probability rows across branches,
1504/// keyed by `sample_id`, under `merge_node`. Each row of every branch block is treated as a
1505/// probability vector over the same `width` classes; rows must be finite, non-negative and sum to
1506/// 1 (within `PROBA_SUM_TOLERANCE`). Like [`reduce_predictions_across_branches`] this is
1507/// asymmetric-branch safe — modelless branches contribute no block — and each sample is averaged
1508/// only over the branches that predicted it. The fused rows are renormalized so each output row is
1509/// itself a valid probability distribution (it already sums to 1 under equal per-branch weight, but
1510/// renormalization keeps the contract exact under floating-point and partial coverage).
1511pub fn reduce_proba_mean_across_branches(
1512    branch_blocks: &[PredictionBlock],
1513    merge_node: &NodeId,
1514) -> Result<PredictionBlock> {
1515    if branch_blocks.is_empty() {
1516        return Err(DagMlError::OofValidation(
1517            "proba-mean fusion needs at least one model-bearing branch".to_string(),
1518        ));
1519    }
1520    for block in branch_blocks {
1521        // Mandatory content gate before the probability checks below: a NaN/Inf would pass the
1522        // `< 0.0` and `sum != 1` comparisons silently (NaN compares false), so the per-row
1523        // probability validation alone cannot reject it — `validate_content` rejects it here.
1524        let width = block.validate_content()?;
1525        if width < 2 {
1526            return Err(DagMlError::OofValidation(format!(
1527                "proba-mean fusion: branch `{}` has width {width}, classification probabilities need at least 2 classes",
1528                block.producer_node
1529            )));
1530        }
1531        for (sample_id, row) in block.sample_ids.iter().zip(&block.values) {
1532            if row.iter().any(|value| *value < 0.0) {
1533                return Err(DagMlError::OofValidation(format!(
1534                    "proba-mean fusion: branch `{}` sample `{sample_id}` has a negative class probability",
1535                    block.producer_node
1536                )));
1537            }
1538            let sum = row.iter().sum::<f64>();
1539            if (sum - 1.0).abs() > PROBA_SUM_TOLERANCE {
1540                return Err(DagMlError::OofValidation(format!(
1541                    "proba-mean fusion: branch `{}` sample `{sample_id}` probabilities sum to {sum}, not 1",
1542                    block.producer_node
1543                )));
1544            }
1545        }
1546    }
1547    let fused = reduce_predictions_across_branches(branch_blocks, None, merge_node)?;
1548    let values = fused
1549        .values
1550        .into_iter()
1551        .map(|row| {
1552            let sum = row.iter().sum::<f64>();
1553            if sum <= 0.0 {
1554                return Err(DagMlError::OofValidation(
1555                    "proba-mean fusion: a fused sample has zero total probability".to_string(),
1556                ));
1557            }
1558            Ok(row.iter().map(|value| value / sum).collect::<Vec<f64>>())
1559        })
1560        .collect::<Result<Vec<_>>>()?;
1561    Ok(PredictionBlock { values, ..fused })
1562}
1563
1564/// Tolerance on the per-row probability-sum check in [`reduce_proba_mean_across_branches`].
1565const PROBA_SUM_TOLERANCE: f64 = 1e-6;
1566
1567#[cfg(test)]
1568mod tests {
1569    use super::*;
1570    use crate::ids::{ControllerId, GroupId, TargetId};
1571    use crate::relation::SampleRelation;
1572
1573    fn sid(value: &str) -> SampleId {
1574        SampleId::new(value).unwrap()
1575    }
1576
1577    fn oid(value: &str) -> ObservationId {
1578        ObservationId::new(value).unwrap()
1579    }
1580
1581    fn relation(observation: &str, sample: &str) -> SampleRelation {
1582        let mut relation = SampleRelation::new(oid(observation), sid(sample));
1583        relation.target_id = Some(TargetId::new(format!("target:{sample}")).unwrap());
1584        relation
1585    }
1586
1587    fn relation_with_units(
1588        observation: &str,
1589        sample: &str,
1590        target: &str,
1591        group: &str,
1592    ) -> SampleRelation {
1593        let mut relation = SampleRelation::new(oid(observation), sid(sample));
1594        relation.target_id = Some(TargetId::new(target).unwrap());
1595        relation.group_id = Some(GroupId::new(group).unwrap());
1596        relation
1597    }
1598
1599    fn combo_relation(observation: &str, sample: &str, components: &[&str]) -> SampleRelation {
1600        let mut relation = SampleRelation::new(oid(observation), sid(sample));
1601        relation.unit_level = EntityUnitLevel::Combo;
1602        relation.derived_unit_id = Some(format!("combo:{observation}"));
1603        relation.component_observation_ids =
1604            components.iter().map(|component| oid(component)).collect();
1605        relation
1606    }
1607
1608    fn custom_policy(level: PredictionLevel) -> AggregationPolicy {
1609        AggregationPolicy {
1610            aggregation_level: level,
1611            method: AggregationMethod::CustomController,
1612            custom_controller: Some(crate::policy::AggregationControllerSpec {
1613                controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1614                params: serde_json::json!({ "trim_fraction": 0.1 }),
1615            }),
1616            ..AggregationPolicy::default()
1617        }
1618    }
1619
1620    #[test]
1621    fn aggregation_controller_result_and_output_are_closed_in_serde_and_schema() {
1622        let document = serde_json::json!({
1623            "schema_version": AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1624            "task_id": "aggregation:strict",
1625            "output": {
1626                "output_kind": "sample",
1627                "block": {
1628                    "prediction_id": null,
1629                    "producer_node": "model:strict",
1630                    "partition": "validation",
1631                    "fold_id": "fold:0",
1632                    "sample_ids": ["sample:1"],
1633                    "values": [[1.0]],
1634                    "target_names": ["y"]
1635                }
1636            }
1637        });
1638        serde_json::from_value::<AggregationControllerResult>(document.clone())
1639            .expect("valid aggregation controller result decodes");
1640
1641        for (label, pointer) in [("result", ""), ("output", "/output")] {
1642            let mut tampered = document.clone();
1643            tampered
1644                .pointer_mut(pointer)
1645                .unwrap()
1646                .as_object_mut()
1647                .unwrap()
1648                .insert(
1649                    "unexpected_contract_field".to_string(),
1650                    serde_json::json!(true),
1651                );
1652            let error = serde_json::from_value::<AggregationControllerResult>(tampered)
1653                .expect_err("unknown aggregation contract field must be rejected");
1654            assert!(
1655                error.to_string().contains("unexpected_contract_field"),
1656                "{label} returned an unexpected error: {error}"
1657            );
1658        }
1659
1660        #[cfg(dag_ml_workspace_contract_fixtures)]
1661        {
1662            let schema: serde_json::Value = serde_json::from_str(include_str!(
1663                "../../../docs/contracts/aggregation_controller_result.schema.json"
1664            ))
1665            .unwrap();
1666            assert_eq!(schema["additionalProperties"].as_bool(), Some(false));
1667            for definition in ["sample_output", "unit_output"] {
1668                assert_eq!(
1669                    schema["$defs"][definition]["additionalProperties"].as_bool(),
1670                    Some(false),
1671                    "aggregation schema definition `{definition}` must be closed"
1672                );
1673            }
1674        }
1675    }
1676
1677    #[test]
1678    fn validates_custom_observation_aggregation_controller_result() {
1679        let reduction_plan = ReductionPlan {
1680            role: crate::policy::ReductionRole::FinalOutput,
1681            axis: ReductionAxis::Unit,
1682            input_unit_level: EntityUnitLevel::Observation,
1683            output_unit_level: EntityUnitLevel::PhysicalSample,
1684            method: ReductionMethod::Custom,
1685            custom_controller: Some(crate::policy::AggregationControllerSpec {
1686                controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1687                params: serde_json::json!({ "trim_fraction": 0.1 }),
1688            }),
1689            ..ReductionPlan::default()
1690        };
1691        let task = AggregationControllerTask {
1692            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1693            task_id: "agg-task:obs.sample.fold0".to_string(),
1694            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1695            policy: custom_policy(PredictionLevel::Sample),
1696            reduction_plan: Some(reduction_plan.clone()),
1697            input: AggregationControllerInput::ObservationToSample {
1698                block: ObservationPredictionBlock {
1699                    prediction_id: Some("prediction:model.fold0".to_string()),
1700                    producer_node: NodeId::new("model:pls").unwrap(),
1701                    producer_port: None,
1702                    partition: PredictionPartition::Validation,
1703                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1704                    observation_ids: vec![oid("obs:1"), oid("obs:2"), oid("obs:3")],
1705                    values: vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![9.0, 10.0]],
1706                    weights: Vec::new(),
1707                    target_names: vec!["moisture".to_string(), "protein".to_string()],
1708                },
1709                relations: SampleRelationSet {
1710                    records: vec![
1711                        relation("obs:1", "sample:1"),
1712                        relation("obs:2", "sample:1"),
1713                        relation("obs:3", "sample:2"),
1714                    ],
1715                },
1716                requested_sample_order: vec![sid("sample:1"), sid("sample:2")],
1717            },
1718        };
1719        task.validate().unwrap();
1720
1721        let result = AggregationControllerResult {
1722            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1723            task_id: task.task_id.clone(),
1724            reduction_plan: Some(reduction_plan),
1725            output: AggregationControllerOutput::Sample {
1726                block: PredictionBlock {
1727                    prediction_id: Some("prediction:model.fold0:custom_sample_agg".to_string()),
1728                    producer_node: NodeId::new("model:pls").unwrap(),
1729                    producer_port: None,
1730                    partition: PredictionPartition::Validation,
1731                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1732                    sample_ids: vec![sid("sample:1"), sid("sample:2")],
1733                    values: vec![vec![2.0, 3.0], vec![9.0, 10.0]],
1734                    target_names: vec!["moisture".to_string(), "protein".to_string()],
1735                },
1736            },
1737        };
1738
1739        result.validate_for_task(&task).unwrap();
1740    }
1741
1742    #[test]
1743    fn custom_aggregation_controller_result_must_echo_reduction_plan() {
1744        let reduction_plan = ReductionPlan {
1745            method: ReductionMethod::Custom,
1746            custom_controller: Some(crate::policy::AggregationControllerSpec {
1747                controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1748                params: serde_json::json!({}),
1749            }),
1750            ..ReductionPlan::default()
1751        };
1752        let task = AggregationControllerTask {
1753            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1754            task_id: "agg-task:obs.sample.fold0".to_string(),
1755            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1756            policy: custom_policy(PredictionLevel::Sample),
1757            reduction_plan: Some(reduction_plan),
1758            input: AggregationControllerInput::ObservationToSample {
1759                block: ObservationPredictionBlock {
1760                    prediction_id: None,
1761                    producer_node: NodeId::new("model:pls").unwrap(),
1762                    producer_port: None,
1763                    partition: PredictionPartition::Validation,
1764                    fold_id: None,
1765                    observation_ids: vec![oid("obs:1")],
1766                    values: vec![vec![1.0]],
1767                    weights: Vec::new(),
1768                    target_names: vec!["y".to_string()],
1769                },
1770                relations: SampleRelationSet {
1771                    records: vec![relation("obs:1", "sample:1")],
1772                },
1773                requested_sample_order: vec![sid("sample:1")],
1774            },
1775        };
1776        let result = AggregationControllerResult {
1777            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1778            task_id: task.task_id.clone(),
1779            reduction_plan: None,
1780            output: AggregationControllerOutput::Sample {
1781                block: PredictionBlock {
1782                    prediction_id: None,
1783                    producer_node: NodeId::new("model:pls").unwrap(),
1784                    producer_port: None,
1785                    partition: PredictionPartition::Validation,
1786                    fold_id: None,
1787                    sample_ids: vec![sid("sample:1")],
1788                    values: vec![vec![1.0]],
1789                    target_names: vec!["y".to_string()],
1790                },
1791            },
1792        };
1793
1794        let error = result.validate_for_task(&task).unwrap_err().to_string();
1795
1796        assert!(error.contains("echo task reduction_plan"));
1797    }
1798
1799    #[test]
1800    fn custom_aggregation_controller_result_refuses_order_mismatch() {
1801        let task = AggregationControllerTask {
1802            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1803            task_id: "agg-task:obs.sample.fold0".to_string(),
1804            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1805            policy: custom_policy(PredictionLevel::Sample),
1806            reduction_plan: None,
1807            input: AggregationControllerInput::ObservationToSample {
1808                block: ObservationPredictionBlock {
1809                    prediction_id: None,
1810                    producer_node: NodeId::new("model:pls").unwrap(),
1811                    producer_port: None,
1812                    partition: PredictionPartition::Validation,
1813                    fold_id: None,
1814                    observation_ids: vec![oid("obs:1"), oid("obs:2")],
1815                    values: vec![vec![1.0], vec![2.0]],
1816                    weights: Vec::new(),
1817                    target_names: vec!["y".to_string()],
1818                },
1819                relations: SampleRelationSet {
1820                    records: vec![relation("obs:1", "sample:1"), relation("obs:2", "sample:2")],
1821                },
1822                requested_sample_order: vec![sid("sample:1"), sid("sample:2")],
1823            },
1824        };
1825        let result = AggregationControllerResult {
1826            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1827            task_id: task.task_id.clone(),
1828            reduction_plan: None,
1829            output: AggregationControllerOutput::Sample {
1830                block: PredictionBlock {
1831                    prediction_id: None,
1832                    producer_node: NodeId::new("model:pls").unwrap(),
1833                    producer_port: None,
1834                    partition: PredictionPartition::Validation,
1835                    fold_id: None,
1836                    sample_ids: vec![sid("sample:2"), sid("sample:1")],
1837                    values: vec![vec![2.0], vec![1.0]],
1838                    target_names: vec!["y".to_string()],
1839                },
1840            },
1841        };
1842
1843        let error = result.validate_for_task(&task).unwrap_err().to_string();
1844        assert!(error.contains("requested sample order"));
1845    }
1846
1847    #[test]
1848    fn validates_custom_sample_to_group_aggregation_controller_result() {
1849        let task = AggregationControllerTask {
1850            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1851            task_id: "agg-task:sample.group.fold0".to_string(),
1852            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1853            policy: custom_policy(PredictionLevel::Group),
1854            reduction_plan: None,
1855            input: AggregationControllerInput::SampleToUnit {
1856                block: PredictionBlock {
1857                    prediction_id: Some("prediction:model.fold0".to_string()),
1858                    producer_node: NodeId::new("model:pls").unwrap(),
1859                    producer_port: None,
1860                    partition: PredictionPartition::Validation,
1861                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1862                    sample_ids: vec![sid("sample:1"), sid("sample:2"), sid("sample:3")],
1863                    values: vec![vec![1.0], vec![3.0], vec![10.0]],
1864                    target_names: vec!["y".to_string()],
1865                },
1866                relations: SampleRelationSet {
1867                    records: vec![
1868                        relation_with_units("obs:1", "sample:1", "target:1", "group:left"),
1869                        relation_with_units("obs:2", "sample:2", "target:2", "group:left"),
1870                        relation_with_units("obs:3", "sample:3", "target:3", "group:right"),
1871                    ],
1872                },
1873                requested_unit_order: vec![
1874                    PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
1875                    PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
1876                ],
1877            },
1878        };
1879        task.validate().unwrap();
1880
1881        let result = AggregationControllerResult {
1882            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1883            task_id: task.task_id.clone(),
1884            reduction_plan: None,
1885            output: AggregationControllerOutput::Unit {
1886                block: AggregatedPredictionBlock {
1887                    prediction_id: Some("prediction:model.fold0:custom_group_agg".to_string()),
1888                    producer_node: NodeId::new("model:pls").unwrap(),
1889                    producer_port: None,
1890                    partition: PredictionPartition::Validation,
1891                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1892                    level: PredictionLevel::Group,
1893                    unit_ids: vec![
1894                        PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
1895                        PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
1896                    ],
1897                    values: vec![vec![2.0], vec![10.0]],
1898                    target_names: vec!["y".to_string()],
1899                },
1900            },
1901        };
1902
1903        result.validate_for_task(&task).unwrap();
1904    }
1905
1906    #[test]
1907    fn averages_repeated_observation_predictions_by_sample() {
1908        let block = ObservationPredictionBlock {
1909            prediction_id: Some("pred:oof".to_string()),
1910            producer_node: NodeId::new("model:pls").unwrap(),
1911            producer_port: None,
1912            partition: PredictionPartition::Validation,
1913            fold_id: Some(FoldId::new("fold:0").unwrap()),
1914            observation_ids: vec![oid("obs:1a"), oid("obs:1b"), oid("obs:2a")],
1915            values: vec![vec![1.0], vec![3.0], vec![10.0]],
1916            weights: Vec::new(),
1917            target_names: vec!["y".to_string()],
1918        };
1919        let relations = SampleRelationSet {
1920            records: vec![
1921                relation("obs:1a", "sample:1"),
1922                relation("obs:1b", "sample:1"),
1923                relation("obs:2a", "sample:2"),
1924            ],
1925        };
1926
1927        let aggregated = aggregate_observation_predictions(
1928            &block,
1929            &relations,
1930            &AggregationPolicy::default(),
1931            &[sid("sample:1"), sid("sample:2")],
1932        )
1933        .unwrap();
1934
1935        assert_eq!(
1936            aggregated.sample_ids,
1937            vec![sid("sample:1"), sid("sample:2")]
1938        );
1939        assert_eq!(aggregated.values, vec![vec![2.0], vec![10.0]]);
1940    }
1941
1942    #[test]
1943    fn aggregates_relation_backed_combo_predictions_by_sample() {
1944        let relations = SampleRelationSet {
1945            records: vec![
1946                relation("obs:s1.a", "sample:1"),
1947                relation("obs:s1.b", "sample:1"),
1948                relation("obs:s2.a", "sample:2"),
1949                relation("obs:s2.b", "sample:2"),
1950                combo_relation("obs:s1.combo", "sample:1", &["obs:s1.a", "obs:s1.b"]),
1951                combo_relation("obs:s2.combo", "sample:2", &["obs:s2.a", "obs:s2.b"]),
1952            ],
1953        };
1954        let block = ObservationPredictionBlock {
1955            prediction_id: Some("pred:combo".to_string()),
1956            producer_node: NodeId::new("model:combo").unwrap(),
1957            producer_port: None,
1958            partition: PredictionPartition::Validation,
1959            fold_id: Some(FoldId::new("fold:0").unwrap()),
1960            observation_ids: vec![oid("obs:s1.combo"), oid("obs:s2.combo")],
1961            values: vec![vec![5.0], vec![9.0]],
1962            weights: Vec::new(),
1963            target_names: vec!["y".to_string()],
1964        };
1965
1966        let aggregated = aggregate_observation_predictions(
1967            &block,
1968            &relations,
1969            &AggregationPolicy::default(),
1970            &[sid("sample:1"), sid("sample:2")],
1971        )
1972        .unwrap();
1973
1974        assert_eq!(aggregated.values, vec![vec![5.0], vec![9.0]]);
1975    }
1976
1977    #[test]
1978    fn robust_mean_trims_extreme_repeated_predictions() {
1979        let observations = (0..10)
1980            .map(|idx| format!("obs:s1.{idx}"))
1981            .collect::<Vec<_>>();
1982        let relations = SampleRelationSet {
1983            records: observations
1984                .iter()
1985                .map(|observation| relation(observation, "sample:1"))
1986                .collect(),
1987        };
1988        let block = ObservationPredictionBlock {
1989            prediction_id: Some("pred:robust".to_string()),
1990            producer_node: NodeId::new("model:pls").unwrap(),
1991            producer_port: None,
1992            partition: PredictionPartition::Validation,
1993            fold_id: Some(FoldId::new("fold:0").unwrap()),
1994            observation_ids: observations
1995                .iter()
1996                .map(|observation| oid(observation))
1997                .collect(),
1998            values: vec![
1999                vec![0.0],
2000                vec![1.0],
2001                vec![2.0],
2002                vec![3.0],
2003                vec![4.0],
2004                vec![5.0],
2005                vec![6.0],
2006                vec![7.0],
2007                vec![8.0],
2008                vec![100.0],
2009            ],
2010            weights: Vec::new(),
2011            target_names: vec!["y".to_string()],
2012        };
2013
2014        let aggregated = aggregate_observation_predictions(
2015            &block,
2016            &relations,
2017            &AggregationPolicy {
2018                method: AggregationMethod::RobustMean,
2019                ..AggregationPolicy::default()
2020            },
2021            &[sid("sample:1")],
2022        )
2023        .unwrap();
2024
2025        assert_eq!(aggregated.values, vec![vec![4.5]]);
2026    }
2027
2028    #[test]
2029    fn exclude_outliers_passes_through_single_repetition() {
2030        // A sample with one observation has nothing to gate on: mean-of-one.
2031        let relations = SampleRelationSet {
2032            records: vec![relation("obs:1", "sample:1")],
2033        };
2034        let block = ObservationPredictionBlock {
2035            prediction_id: None,
2036            producer_node: NodeId::new("model:pls").unwrap(),
2037            producer_port: None,
2038            partition: PredictionPartition::Validation,
2039            fold_id: None,
2040            observation_ids: vec![oid("obs:1")],
2041            values: vec![vec![1.0]],
2042            weights: Vec::new(),
2043            target_names: vec!["y".to_string()],
2044        };
2045
2046        let aggregated = aggregate_observation_predictions(
2047            &block,
2048            &relations,
2049            &AggregationPolicy {
2050                method: AggregationMethod::ExcludeOutliers,
2051                ..AggregationPolicy::default()
2052            },
2053            &[sid("sample:1")],
2054        )
2055        .unwrap();
2056
2057        assert_eq!(aggregated.values, vec![vec![1.0]]);
2058    }
2059
2060    #[test]
2061    fn aggregates_repeated_predictions_with_median_vote_and_weights() {
2062        let relations = SampleRelationSet {
2063            records: vec![
2064                relation("obs:1a", "sample:1"),
2065                relation("obs:1b", "sample:1"),
2066                relation("obs:1c", "sample:1"),
2067                relation("obs:2a", "sample:2"),
2068                relation("obs:2b", "sample:2"),
2069            ],
2070        };
2071        let base_block = ObservationPredictionBlock {
2072            prediction_id: Some("pred:oof".to_string()),
2073            producer_node: NodeId::new("model:pls").unwrap(),
2074            producer_port: None,
2075            partition: PredictionPartition::Validation,
2076            fold_id: Some(FoldId::new("fold:0").unwrap()),
2077            observation_ids: vec![
2078                oid("obs:1a"),
2079                oid("obs:1b"),
2080                oid("obs:1c"),
2081                oid("obs:2a"),
2082                oid("obs:2b"),
2083            ],
2084            values: vec![
2085                vec![1.0, 0.0],
2086                vec![5.0, 1.0],
2087                vec![9.0, 1.0],
2088                vec![10.0, 2.0],
2089                vec![30.0, 3.0],
2090            ],
2091            weights: Vec::new(),
2092            target_names: vec!["regression".to_string(), "class".to_string()],
2093        };
2094        let sample_order = [sid("sample:1"), sid("sample:2")];
2095
2096        let median_policy = AggregationPolicy {
2097            method: AggregationMethod::Median,
2098            ..AggregationPolicy::default()
2099        };
2100        let median = aggregate_observation_predictions(
2101            &base_block,
2102            &relations,
2103            &median_policy,
2104            &sample_order,
2105        )
2106        .unwrap();
2107        assert_eq!(median.values, vec![vec![5.0, 1.0], vec![20.0, 2.5]]);
2108
2109        let vote_policy = AggregationPolicy {
2110            method: AggregationMethod::Vote,
2111            ..AggregationPolicy::default()
2112        };
2113        let vote =
2114            aggregate_observation_predictions(&base_block, &relations, &vote_policy, &sample_order)
2115                .unwrap();
2116        assert_eq!(vote.values, vec![vec![1.0, 1.0], vec![10.0, 2.0]]);
2117
2118        let mut weighted_block = base_block;
2119        weighted_block.weights = vec![1.0, 1.0, 2.0, 1.0, 3.0];
2120        let weighted_policy = AggregationPolicy {
2121            method: AggregationMethod::WeightedMean,
2122            weights: AggregationWeights::ControllerEmitted,
2123            ..AggregationPolicy::default()
2124        };
2125        let weighted = aggregate_observation_predictions(
2126            &weighted_block,
2127            &relations,
2128            &weighted_policy,
2129            &sample_order,
2130        )
2131        .unwrap();
2132        assert_eq!(weighted.values, vec![vec![6.0, 0.75], vec![25.0, 2.75]]);
2133    }
2134
2135    #[test]
2136    fn refuses_incompatible_observation_weight_contracts() {
2137        let relations = SampleRelationSet {
2138            records: vec![
2139                relation("obs:1a", "sample:1"),
2140                relation("obs:1b", "sample:1"),
2141            ],
2142        };
2143        let block = ObservationPredictionBlock {
2144            prediction_id: None,
2145            producer_node: NodeId::new("model:pls").unwrap(),
2146            producer_port: None,
2147            partition: PredictionPartition::Validation,
2148            fold_id: None,
2149            observation_ids: vec![oid("obs:1a"), oid("obs:1b")],
2150            values: vec![vec![1.0], vec![2.0]],
2151            weights: vec![1.0, 2.0],
2152            target_names: vec!["y".to_string()],
2153        };
2154
2155        let mean_error = aggregate_observation_predictions(
2156            &block,
2157            &relations,
2158            &AggregationPolicy::default(),
2159            &[sid("sample:1")],
2160        )
2161        .unwrap_err()
2162        .to_string();
2163        assert!(
2164            mean_error.contains("non-weighted aggregation"),
2165            "unexpected mean error: {mean_error}"
2166        );
2167
2168        let mut missing_weights_block = block;
2169        missing_weights_block.weights.clear();
2170        let weighted_error = aggregate_observation_predictions(
2171            &missing_weights_block,
2172            &relations,
2173            &AggregationPolicy {
2174                method: AggregationMethod::WeightedMean,
2175                weights: AggregationWeights::ControllerEmitted,
2176                ..AggregationPolicy::default()
2177            },
2178            &[sid("sample:1")],
2179        )
2180        .unwrap_err()
2181        .to_string();
2182        assert!(
2183            weighted_error.contains("requires one weight per observation"),
2184            "unexpected weighted error: {weighted_error}"
2185        );
2186    }
2187
2188    #[test]
2189    fn aggregates_sample_predictions_to_target_and_group_units() {
2190        let relations = SampleRelationSet {
2191            records: vec![
2192                relation_with_units("obs:s1:a", "sample:1", "target:a", "group:left"),
2193                relation_with_units("obs:s1:b", "sample:1", "target:a", "group:left"),
2194                relation_with_units("obs:s2:a", "sample:2", "target:a", "group:left"),
2195                relation_with_units("obs:s3:a", "sample:3", "target:b", "group:right"),
2196            ],
2197        };
2198        let block = PredictionBlock {
2199            prediction_id: Some("pred:sample".to_string()),
2200            producer_node: NodeId::new("model:pls").unwrap(),
2201            producer_port: None,
2202            partition: PredictionPartition::Validation,
2203            fold_id: Some(FoldId::new("fold:0").unwrap()),
2204            sample_ids: vec![sid("sample:1"), sid("sample:2"), sid("sample:3")],
2205            values: vec![vec![10.0], vec![4.0], vec![30.0]],
2206            target_names: vec!["y".to_string()],
2207        };
2208
2209        let target_policy = AggregationPolicy {
2210            aggregation_level: PredictionLevel::Target,
2211            method: AggregationMethod::Mean,
2212            ..AggregationPolicy::default()
2213        };
2214        let by_target = aggregate_sample_predictions_by_unit(
2215            &block,
2216            &relations,
2217            &target_policy,
2218            &[
2219                PredictionUnitId::Target(TargetId::new("target:a").unwrap()),
2220                PredictionUnitId::Target(TargetId::new("target:b").unwrap()),
2221            ],
2222        )
2223        .unwrap();
2224        assert_eq!(by_target.level, PredictionLevel::Target);
2225        assert_eq!(by_target.values, vec![vec![7.0], vec![30.0]]);
2226
2227        let group_policy = AggregationPolicy {
2228            aggregation_level: PredictionLevel::Group,
2229            method: AggregationMethod::WeightedMean,
2230            weights: AggregationWeights::RepetitionCount,
2231            ..AggregationPolicy::default()
2232        };
2233        let by_group = aggregate_sample_predictions_by_unit(
2234            &block,
2235            &relations,
2236            &group_policy,
2237            &[
2238                PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
2239                PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
2240            ],
2241        )
2242        .unwrap();
2243        assert_eq!(by_group.level, PredictionLevel::Group);
2244        assert_eq!(by_group.values, vec![vec![8.0], vec![30.0]]);
2245    }
2246
2247    #[test]
2248    fn refuses_target_group_aggregation_without_relation_units() {
2249        let relations = SampleRelationSet {
2250            records: vec![SampleRelation::new(oid("obs:1"), sid("sample:1"))],
2251        };
2252        let block = PredictionBlock {
2253            prediction_id: None,
2254            producer_node: NodeId::new("model:pls").unwrap(),
2255            producer_port: None,
2256            partition: PredictionPartition::Validation,
2257            fold_id: None,
2258            sample_ids: vec![sid("sample:1")],
2259            values: vec![vec![1.0]],
2260            target_names: vec!["y".to_string()],
2261        };
2262
2263        let error = aggregate_sample_predictions_by_unit(
2264            &block,
2265            &relations,
2266            &AggregationPolicy {
2267                aggregation_level: PredictionLevel::Target,
2268                method: AggregationMethod::Mean,
2269                ..AggregationPolicy::default()
2270            },
2271            &[PredictionUnitId::Target(
2272                TargetId::new("target:missing").unwrap(),
2273            )],
2274        )
2275        .unwrap_err()
2276        .to_string();
2277        assert!(
2278            error.contains("missing target id"),
2279            "unexpected target aggregation error: {error}"
2280        );
2281    }
2282
2283    #[test]
2284    fn refuses_missing_observation_relation() {
2285        let block = ObservationPredictionBlock {
2286            prediction_id: None,
2287            producer_node: NodeId::new("model:pls").unwrap(),
2288            producer_port: None,
2289            partition: PredictionPartition::Validation,
2290            fold_id: None,
2291            observation_ids: vec![oid("obs:missing")],
2292            values: vec![vec![1.0]],
2293            weights: Vec::new(),
2294            target_names: vec!["y".to_string()],
2295        };
2296
2297        assert!(aggregate_observation_predictions(
2298            &block,
2299            &SampleRelationSet::default(),
2300            &AggregationPolicy::default(),
2301            &[sid("sample:1")]
2302        )
2303        .is_err());
2304    }
2305
2306    #[test]
2307    fn cross_fold_reduction_concats_disjoint_and_averages_shared() {
2308        let node = NodeId::new("model:pls").unwrap();
2309        let block = |fold: &str, rows: &[(&str, f64)]| PredictionBlock {
2310            prediction_id: None,
2311            producer_node: node.clone(),
2312            producer_port: None,
2313            partition: PredictionPartition::Validation,
2314            fold_id: Some(FoldId::new(fold).unwrap()),
2315            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2316            values: rows.iter().map(|(_, v)| vec![*v]).collect(),
2317            target_names: vec!["y".to_string()],
2318        };
2319
2320        // Disjoint folds (the OOF/validation case) -> concat, each sample once, value unchanged.
2321        let oof = reduce_predictions_across_folds(
2322            &[
2323                block("fold0", &[("s1", 1.0), ("s2", 2.0)]),
2324                block("fold1", &[("s3", 3.0), ("s4", 4.0)]),
2325            ],
2326            None,
2327            "avg",
2328        )
2329        .unwrap();
2330        assert_eq!(oof.sample_ids.len(), 4);
2331        assert_eq!(oof.fold_id, Some(FoldId::new("avg").unwrap()));
2332        assert_eq!(oof.values, vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]]);
2333
2334        // Shared folds (the test case, each fold predicts the same samples) -> mean per sample.
2335        let shared = [
2336            block("fold0", &[("t1", 0.0), ("t2", 10.0)]),
2337            block("fold1", &[("t1", 4.0), ("t2", 20.0)]),
2338        ];
2339        let avg = reduce_predictions_across_folds(&shared, None, "avg").unwrap();
2340        assert_eq!(avg.values, vec![vec![2.0], vec![15.0]]);
2341
2342        // w_avg with fold weights [1, 3]: (0*1+4*3)/4=3 ; (10*1+20*3)/4=17.5.
2343        let wavg = reduce_predictions_across_folds(&shared, Some(&[1.0, 3.0]), "w_avg").unwrap();
2344        assert_eq!(wavg.values, vec![vec![3.0], vec![17.5]]);
2345        assert_eq!(wavg.fold_id, Some(FoldId::new("w_avg").unwrap()));
2346
2347        // Mismatched weights are rejected.
2348        assert!(reduce_predictions_across_folds(
2349            &[block("f0", &[("a", 1.0)])],
2350            Some(&[1.0, 2.0]),
2351            "avg"
2352        )
2353        .is_err());
2354    }
2355
2356    #[test]
2357    fn exclude_outliers_drops_hotelling_t2_extreme_repetition() {
2358        // 6 repetitions of one sample; the 100.0 row is a gross outlier and must be
2359        // excluded before the mean. Survivors {1,2,3,4,5} -> mean 3.0.
2360        let observations = (0..6)
2361            .map(|idx| format!("obs:s1.{idx}"))
2362            .collect::<Vec<_>>();
2363        let relations = SampleRelationSet {
2364            records: observations
2365                .iter()
2366                .map(|observation| relation(observation, "sample:1"))
2367                .collect(),
2368        };
2369        let block = ObservationPredictionBlock {
2370            prediction_id: Some("pred:t2".to_string()),
2371            producer_node: NodeId::new("model:pls").unwrap(),
2372            producer_port: None,
2373            partition: PredictionPartition::Validation,
2374            fold_id: Some(FoldId::new("fold:0").unwrap()),
2375            observation_ids: observations
2376                .iter()
2377                .map(|observation| oid(observation))
2378                .collect(),
2379            values: vec![
2380                vec![1.0],
2381                vec![2.0],
2382                vec![3.0],
2383                vec![4.0],
2384                vec![5.0],
2385                vec![100.0],
2386            ],
2387            weights: Vec::new(),
2388            target_names: vec!["y".to_string()],
2389        };
2390
2391        let aggregated = aggregate_observation_predictions(
2392            &block,
2393            &relations,
2394            &AggregationPolicy {
2395                method: AggregationMethod::ExcludeOutliers,
2396                ..AggregationPolicy::default()
2397            },
2398            &[sid("sample:1")],
2399        )
2400        .unwrap();
2401
2402        assert_eq!(aggregated.values, vec![vec![3.0]]);
2403    }
2404
2405    #[test]
2406    fn exclude_outliers_keeps_small_or_constant_repetition_sets() {
2407        // Two repetitions: too few rows to gate on robust spread -> plain mean.
2408        let small_relations = SampleRelationSet {
2409            records: vec![
2410                relation("obs:1a", "sample:1"),
2411                relation("obs:1b", "sample:1"),
2412            ],
2413        };
2414        let small_block = ObservationPredictionBlock {
2415            prediction_id: None,
2416            producer_node: NodeId::new("model:pls").unwrap(),
2417            producer_port: None,
2418            partition: PredictionPartition::Validation,
2419            fold_id: None,
2420            observation_ids: vec![oid("obs:1a"), oid("obs:1b")],
2421            values: vec![vec![1.0], vec![9.0]],
2422            weights: Vec::new(),
2423            target_names: vec!["y".to_string()],
2424        };
2425        let small = aggregate_observation_predictions(
2426            &small_block,
2427            &small_relations,
2428            &AggregationPolicy {
2429                method: AggregationMethod::ExcludeOutliers,
2430                ..AggregationPolicy::default()
2431            },
2432            &[sid("sample:1")],
2433        )
2434        .unwrap();
2435        assert_eq!(small.values, vec![vec![5.0]]);
2436
2437        // Constant target across many repetitions: zero variance, nothing flagged.
2438        let observations = (0..5).map(|idx| format!("obs:c.{idx}")).collect::<Vec<_>>();
2439        let constant_relations = SampleRelationSet {
2440            records: observations
2441                .iter()
2442                .map(|observation| relation(observation, "sample:1"))
2443                .collect(),
2444        };
2445        let constant_block = ObservationPredictionBlock {
2446            prediction_id: None,
2447            producer_node: NodeId::new("model:pls").unwrap(),
2448            producer_port: None,
2449            partition: PredictionPartition::Validation,
2450            fold_id: None,
2451            observation_ids: observations
2452                .iter()
2453                .map(|observation| oid(observation))
2454                .collect(),
2455            values: vec![vec![7.0]; 5],
2456            weights: Vec::new(),
2457            target_names: vec!["y".to_string()],
2458        };
2459        let constant = aggregate_observation_predictions(
2460            &constant_block,
2461            &constant_relations,
2462            &AggregationPolicy {
2463                method: AggregationMethod::ExcludeOutliers,
2464                ..AggregationPolicy::default()
2465            },
2466            &[sid("sample:1")],
2467        )
2468        .unwrap();
2469        assert_eq!(constant.values, vec![vec![7.0]]);
2470    }
2471
2472    #[test]
2473    fn exclude_outliers_aggregates_sample_predictions_to_unit() {
2474        // Sample-to-group path: group:left has a gross outlier sample to exclude.
2475        let relations = SampleRelationSet {
2476            records: vec![
2477                relation_with_units("o:1", "sample:1", "t:a", "group:left"),
2478                relation_with_units("o:2", "sample:2", "t:a", "group:left"),
2479                relation_with_units("o:3", "sample:3", "t:a", "group:left"),
2480                relation_with_units("o:4", "sample:4", "t:a", "group:left"),
2481                relation_with_units("o:5", "sample:5", "t:b", "group:right"),
2482            ],
2483        };
2484        let block = PredictionBlock {
2485            prediction_id: Some("pred:sample".to_string()),
2486            producer_node: NodeId::new("model:pls").unwrap(),
2487            producer_port: None,
2488            partition: PredictionPartition::Validation,
2489            fold_id: Some(FoldId::new("fold:0").unwrap()),
2490            sample_ids: vec![
2491                sid("sample:1"),
2492                sid("sample:2"),
2493                sid("sample:3"),
2494                sid("sample:4"),
2495                sid("sample:5"),
2496            ],
2497            values: vec![vec![1.0], vec![2.0], vec![3.0], vec![100.0], vec![42.0]],
2498            target_names: vec!["y".to_string()],
2499        };
2500
2501        let aggregated = aggregate_sample_predictions_by_unit(
2502            &block,
2503            &relations,
2504            &AggregationPolicy {
2505                aggregation_level: PredictionLevel::Group,
2506                method: AggregationMethod::ExcludeOutliers,
2507                ..AggregationPolicy::default()
2508            },
2509            &[
2510                PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
2511                PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
2512            ],
2513        )
2514        .unwrap();
2515        // group:left survivors {1,2,3} -> 2.0 ; group:right single sample -> 42.0.
2516        assert_eq!(aggregated.values, vec![vec![2.0], vec![42.0]]);
2517    }
2518
2519    #[test]
2520    fn cross_branch_reduction_averages_only_covering_branches() {
2521        let merge = NodeId::new("merge:branch.fusion").unwrap();
2522        let branch = |producer: &str, rows: &[(&str, f64)]| PredictionBlock {
2523            prediction_id: None,
2524            producer_node: NodeId::new(producer).unwrap(),
2525            producer_port: None,
2526            partition: PredictionPartition::Validation,
2527            fold_id: Some(FoldId::new("fold:0").unwrap()),
2528            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2529            values: rows.iter().map(|(_, v)| vec![*v]).collect(),
2530            target_names: vec!["y".to_string()],
2531        };
2532
2533        // Two model-bearing branches with DIFFERENT producers; b1 does not cover s3
2534        // (asymmetric coverage). s1 covered by both -> mean; s3 only by b0 -> b0 value.
2535        let fused = reduce_predictions_across_branches(
2536            &[
2537                branch(
2538                    "branch:b0.model:ridge",
2539                    &[("s1", 10.0), ("s2", 4.0), ("s3", 6.0)],
2540                ),
2541                branch("branch:b1.model:rf", &[("s1", 20.0), ("s2", 8.0)]),
2542            ],
2543            None,
2544            &merge,
2545        )
2546        .unwrap();
2547        assert_eq!(fused.producer_node, merge);
2548        assert_eq!(fused.sample_ids, vec![sid("s1"), sid("s2"), sid("s3")]);
2549        assert_eq!(fused.values, vec![vec![15.0], vec![6.0], vec![6.0]]);
2550
2551        // Per-branch ensemble weights [1, 3]: s1 -> (10*1 + 20*3)/4 = 17.5.
2552        let weighted = reduce_predictions_across_branches(
2553            &[
2554                branch("branch:b0.model:ridge", &[("s1", 10.0)]),
2555                branch("branch:b1.model:rf", &[("s1", 20.0)]),
2556            ],
2557            Some(&[1.0, 3.0]),
2558            &merge,
2559        )
2560        .unwrap();
2561        assert_eq!(weighted.values, vec![vec![17.5]]);
2562
2563        // Empty branch set (every branch modelless) is rejected.
2564        assert!(reduce_predictions_across_branches(&[], None, &merge).is_err());
2565        // Mismatched per-branch weights are rejected.
2566        assert!(reduce_predictions_across_branches(
2567            &[branch("branch:b0", &[("s1", 1.0)])],
2568            Some(&[1.0, 2.0]),
2569            &merge
2570        )
2571        .is_err());
2572        // Branches with mismatched target names are rejected.
2573        let mut other_targets = branch("branch:b1", &[("s1", 1.0)]);
2574        other_targets.target_names = vec!["z".to_string()];
2575        assert!(reduce_predictions_across_branches(
2576            &[branch("branch:b0", &[("s1", 1.0)]), other_targets],
2577            None,
2578            &merge
2579        )
2580        .is_err());
2581    }
2582
2583    #[test]
2584    fn proba_mean_fusion_averages_and_renormalizes_class_probabilities() {
2585        let merge = NodeId::new("merge:proba.fusion").unwrap();
2586        let branch = |producer: &str, rows: &[(&str, [f64; 2])]| PredictionBlock {
2587            prediction_id: None,
2588            producer_node: NodeId::new(producer).unwrap(),
2589            producer_port: None,
2590            partition: PredictionPartition::Validation,
2591            fold_id: None,
2592            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2593            values: rows.iter().map(|(_, p)| p.to_vec()).collect(),
2594            target_names: vec!["neg".to_string(), "pos".to_string()],
2595        };
2596
2597        let fused = reduce_proba_mean_across_branches(
2598            &[
2599                branch(
2600                    "branch:b0.model:lr",
2601                    &[("s1", [0.8, 0.2]), ("s2", [0.4, 0.6])],
2602                ),
2603                branch(
2604                    "branch:b1.model:svc",
2605                    &[("s1", [0.6, 0.4]), ("s2", [0.2, 0.8])],
2606                ),
2607            ],
2608            &merge,
2609        )
2610        .unwrap();
2611        assert_eq!(fused.producer_node, merge);
2612        // s1 -> [(0.8+0.6)/2, (0.2+0.4)/2] = [0.7, 0.3] ; s2 -> [0.3, 0.7].
2613        for (row, expected) in fused.values.iter().zip([[0.7, 0.3], [0.3, 0.7]]) {
2614            for (value, want) in row.iter().zip(expected) {
2615                assert!((value - want).abs() < 1e-12, "got {value}, want {want}");
2616            }
2617            assert!((row.iter().sum::<f64>() - 1.0).abs() < 1e-12);
2618        }
2619
2620        // Asymmetric coverage: only b0 predicts s2 -> its row passes through.
2621        let asymmetric = reduce_proba_mean_across_branches(
2622            &[
2623                branch(
2624                    "branch:b0.model:lr",
2625                    &[("s1", [0.9, 0.1]), ("s2", [0.3, 0.7])],
2626                ),
2627                branch("branch:b1.model:svc", &[("s1", [0.5, 0.5])]),
2628            ],
2629            &merge,
2630        )
2631        .unwrap();
2632        assert_eq!(asymmetric.sample_ids, vec![sid("s1"), sid("s2")]);
2633        assert!((asymmetric.values[1][1] - 0.7).abs() < 1e-12);
2634
2635        // Rows that are not probability vectors are rejected.
2636        assert!(reduce_proba_mean_across_branches(
2637            &[branch("branch:b0", &[("s1", [0.8, 0.4])])],
2638            &merge
2639        )
2640        .is_err());
2641        assert!(reduce_proba_mean_across_branches(
2642            &[branch("branch:b0", &[("s1", [1.2, -0.2])])],
2643            &merge
2644        )
2645        .is_err());
2646        // Empty branch set is rejected.
2647        assert!(reduce_proba_mean_across_branches(&[], &merge).is_err());
2648
2649        // A NaN class probability must be rejected — it would slip past the `< 0.0` and
2650        // `sum != 1` checks (NaN compares false) but is caught by the content gate.
2651        assert!(reduce_proba_mean_across_branches(
2652            &[branch("branch:b0", &[("s1", [f64::NAN, 0.5])])],
2653            &merge
2654        )
2655        .is_err());
2656    }
2657
2658    #[test]
2659    fn cross_fold_reduction_rejects_within_fold_duplicate_and_non_finite() {
2660        let node = NodeId::new("model:pls").unwrap();
2661        let block = |fold: &str, rows: &[(&str, f64)]| PredictionBlock {
2662            prediction_id: None,
2663            producer_node: node.clone(),
2664            producer_port: None,
2665            partition: PredictionPartition::Validation,
2666            fold_id: Some(FoldId::new(fold).unwrap()),
2667            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2668            values: rows.iter().map(|(_, v)| vec![*v]).collect(),
2669            target_names: vec!["y".to_string()],
2670        };
2671
2672        // A within-fold duplicate sample would be averaged in twice (double-count) — rejected.
2673        let dup = block("fold0", &[("s1", 1.0), ("s1", 3.0)]);
2674        let err = reduce_predictions_across_folds(&[dup], None, "avg").unwrap_err();
2675        assert!(
2676            err.to_string().contains("duplicate prediction"),
2677            "got: {err}"
2678        );
2679
2680        // A non-finite value would poison the fused mean — rejected.
2681        let poisoned = block("fold0", &[("s1", f64::NAN), ("s2", 2.0)]);
2682        let err = reduce_predictions_across_folds(&[poisoned], None, "avg").unwrap_err();
2683        assert!(err.to_string().contains("non-finite"), "got: {err}");
2684    }
2685}