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        let schema: serde_json::Value = serde_json::from_str(include_str!(
1661            "../../../docs/contracts/aggregation_controller_result.schema.json"
1662        ))
1663        .unwrap();
1664        assert_eq!(schema["additionalProperties"].as_bool(), Some(false));
1665        for definition in ["sample_output", "unit_output"] {
1666            assert_eq!(
1667                schema["$defs"][definition]["additionalProperties"].as_bool(),
1668                Some(false),
1669                "aggregation schema definition `{definition}` must be closed"
1670            );
1671        }
1672    }
1673
1674    #[test]
1675    fn validates_custom_observation_aggregation_controller_result() {
1676        let reduction_plan = ReductionPlan {
1677            role: crate::policy::ReductionRole::FinalOutput,
1678            axis: ReductionAxis::Unit,
1679            input_unit_level: EntityUnitLevel::Observation,
1680            output_unit_level: EntityUnitLevel::PhysicalSample,
1681            method: ReductionMethod::Custom,
1682            custom_controller: Some(crate::policy::AggregationControllerSpec {
1683                controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1684                params: serde_json::json!({ "trim_fraction": 0.1 }),
1685            }),
1686            ..ReductionPlan::default()
1687        };
1688        let task = AggregationControllerTask {
1689            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1690            task_id: "agg-task:obs.sample.fold0".to_string(),
1691            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1692            policy: custom_policy(PredictionLevel::Sample),
1693            reduction_plan: Some(reduction_plan.clone()),
1694            input: AggregationControllerInput::ObservationToSample {
1695                block: ObservationPredictionBlock {
1696                    prediction_id: Some("prediction:model.fold0".to_string()),
1697                    producer_node: NodeId::new("model:pls").unwrap(),
1698                    producer_port: None,
1699                    partition: PredictionPartition::Validation,
1700                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1701                    observation_ids: vec![oid("obs:1"), oid("obs:2"), oid("obs:3")],
1702                    values: vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![9.0, 10.0]],
1703                    weights: Vec::new(),
1704                    target_names: vec!["moisture".to_string(), "protein".to_string()],
1705                },
1706                relations: SampleRelationSet {
1707                    records: vec![
1708                        relation("obs:1", "sample:1"),
1709                        relation("obs:2", "sample:1"),
1710                        relation("obs:3", "sample:2"),
1711                    ],
1712                },
1713                requested_sample_order: vec![sid("sample:1"), sid("sample:2")],
1714            },
1715        };
1716        task.validate().unwrap();
1717
1718        let result = AggregationControllerResult {
1719            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1720            task_id: task.task_id.clone(),
1721            reduction_plan: Some(reduction_plan),
1722            output: AggregationControllerOutput::Sample {
1723                block: PredictionBlock {
1724                    prediction_id: Some("prediction:model.fold0:custom_sample_agg".to_string()),
1725                    producer_node: NodeId::new("model:pls").unwrap(),
1726                    producer_port: None,
1727                    partition: PredictionPartition::Validation,
1728                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1729                    sample_ids: vec![sid("sample:1"), sid("sample:2")],
1730                    values: vec![vec![2.0, 3.0], vec![9.0, 10.0]],
1731                    target_names: vec!["moisture".to_string(), "protein".to_string()],
1732                },
1733            },
1734        };
1735
1736        result.validate_for_task(&task).unwrap();
1737    }
1738
1739    #[test]
1740    fn custom_aggregation_controller_result_must_echo_reduction_plan() {
1741        let reduction_plan = ReductionPlan {
1742            method: ReductionMethod::Custom,
1743            custom_controller: Some(crate::policy::AggregationControllerSpec {
1744                controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1745                params: serde_json::json!({}),
1746            }),
1747            ..ReductionPlan::default()
1748        };
1749        let task = AggregationControllerTask {
1750            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1751            task_id: "agg-task:obs.sample.fold0".to_string(),
1752            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1753            policy: custom_policy(PredictionLevel::Sample),
1754            reduction_plan: Some(reduction_plan),
1755            input: AggregationControllerInput::ObservationToSample {
1756                block: ObservationPredictionBlock {
1757                    prediction_id: None,
1758                    producer_node: NodeId::new("model:pls").unwrap(),
1759                    producer_port: None,
1760                    partition: PredictionPartition::Validation,
1761                    fold_id: None,
1762                    observation_ids: vec![oid("obs:1")],
1763                    values: vec![vec![1.0]],
1764                    weights: Vec::new(),
1765                    target_names: vec!["y".to_string()],
1766                },
1767                relations: SampleRelationSet {
1768                    records: vec![relation("obs:1", "sample:1")],
1769                },
1770                requested_sample_order: vec![sid("sample:1")],
1771            },
1772        };
1773        let result = AggregationControllerResult {
1774            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1775            task_id: task.task_id.clone(),
1776            reduction_plan: None,
1777            output: AggregationControllerOutput::Sample {
1778                block: PredictionBlock {
1779                    prediction_id: None,
1780                    producer_node: NodeId::new("model:pls").unwrap(),
1781                    producer_port: None,
1782                    partition: PredictionPartition::Validation,
1783                    fold_id: None,
1784                    sample_ids: vec![sid("sample:1")],
1785                    values: vec![vec![1.0]],
1786                    target_names: vec!["y".to_string()],
1787                },
1788            },
1789        };
1790
1791        let error = result.validate_for_task(&task).unwrap_err().to_string();
1792
1793        assert!(error.contains("echo task reduction_plan"));
1794    }
1795
1796    #[test]
1797    fn custom_aggregation_controller_result_refuses_order_mismatch() {
1798        let task = AggregationControllerTask {
1799            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1800            task_id: "agg-task:obs.sample.fold0".to_string(),
1801            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1802            policy: custom_policy(PredictionLevel::Sample),
1803            reduction_plan: None,
1804            input: AggregationControllerInput::ObservationToSample {
1805                block: ObservationPredictionBlock {
1806                    prediction_id: None,
1807                    producer_node: NodeId::new("model:pls").unwrap(),
1808                    producer_port: None,
1809                    partition: PredictionPartition::Validation,
1810                    fold_id: None,
1811                    observation_ids: vec![oid("obs:1"), oid("obs:2")],
1812                    values: vec![vec![1.0], vec![2.0]],
1813                    weights: Vec::new(),
1814                    target_names: vec!["y".to_string()],
1815                },
1816                relations: SampleRelationSet {
1817                    records: vec![relation("obs:1", "sample:1"), relation("obs:2", "sample:2")],
1818                },
1819                requested_sample_order: vec![sid("sample:1"), sid("sample:2")],
1820            },
1821        };
1822        let result = AggregationControllerResult {
1823            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1824            task_id: task.task_id.clone(),
1825            reduction_plan: None,
1826            output: AggregationControllerOutput::Sample {
1827                block: PredictionBlock {
1828                    prediction_id: None,
1829                    producer_node: NodeId::new("model:pls").unwrap(),
1830                    producer_port: None,
1831                    partition: PredictionPartition::Validation,
1832                    fold_id: None,
1833                    sample_ids: vec![sid("sample:2"), sid("sample:1")],
1834                    values: vec![vec![2.0], vec![1.0]],
1835                    target_names: vec!["y".to_string()],
1836                },
1837            },
1838        };
1839
1840        let error = result.validate_for_task(&task).unwrap_err().to_string();
1841        assert!(error.contains("requested sample order"));
1842    }
1843
1844    #[test]
1845    fn validates_custom_sample_to_group_aggregation_controller_result() {
1846        let task = AggregationControllerTask {
1847            schema_version: AGGREGATION_CONTROLLER_TASK_SCHEMA_VERSION,
1848            task_id: "agg-task:sample.group.fold0".to_string(),
1849            controller_id: ControllerId::new("controller:agg.trimmed").unwrap(),
1850            policy: custom_policy(PredictionLevel::Group),
1851            reduction_plan: None,
1852            input: AggregationControllerInput::SampleToUnit {
1853                block: PredictionBlock {
1854                    prediction_id: Some("prediction:model.fold0".to_string()),
1855                    producer_node: NodeId::new("model:pls").unwrap(),
1856                    producer_port: None,
1857                    partition: PredictionPartition::Validation,
1858                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1859                    sample_ids: vec![sid("sample:1"), sid("sample:2"), sid("sample:3")],
1860                    values: vec![vec![1.0], vec![3.0], vec![10.0]],
1861                    target_names: vec!["y".to_string()],
1862                },
1863                relations: SampleRelationSet {
1864                    records: vec![
1865                        relation_with_units("obs:1", "sample:1", "target:1", "group:left"),
1866                        relation_with_units("obs:2", "sample:2", "target:2", "group:left"),
1867                        relation_with_units("obs:3", "sample:3", "target:3", "group:right"),
1868                    ],
1869                },
1870                requested_unit_order: vec![
1871                    PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
1872                    PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
1873                ],
1874            },
1875        };
1876        task.validate().unwrap();
1877
1878        let result = AggregationControllerResult {
1879            schema_version: AGGREGATION_CONTROLLER_RESULT_SCHEMA_VERSION,
1880            task_id: task.task_id.clone(),
1881            reduction_plan: None,
1882            output: AggregationControllerOutput::Unit {
1883                block: AggregatedPredictionBlock {
1884                    prediction_id: Some("prediction:model.fold0:custom_group_agg".to_string()),
1885                    producer_node: NodeId::new("model:pls").unwrap(),
1886                    producer_port: None,
1887                    partition: PredictionPartition::Validation,
1888                    fold_id: Some(FoldId::new("fold:0").unwrap()),
1889                    level: PredictionLevel::Group,
1890                    unit_ids: vec![
1891                        PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
1892                        PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
1893                    ],
1894                    values: vec![vec![2.0], vec![10.0]],
1895                    target_names: vec!["y".to_string()],
1896                },
1897            },
1898        };
1899
1900        result.validate_for_task(&task).unwrap();
1901    }
1902
1903    #[test]
1904    fn averages_repeated_observation_predictions_by_sample() {
1905        let block = ObservationPredictionBlock {
1906            prediction_id: Some("pred:oof".to_string()),
1907            producer_node: NodeId::new("model:pls").unwrap(),
1908            producer_port: None,
1909            partition: PredictionPartition::Validation,
1910            fold_id: Some(FoldId::new("fold:0").unwrap()),
1911            observation_ids: vec![oid("obs:1a"), oid("obs:1b"), oid("obs:2a")],
1912            values: vec![vec![1.0], vec![3.0], vec![10.0]],
1913            weights: Vec::new(),
1914            target_names: vec!["y".to_string()],
1915        };
1916        let relations = SampleRelationSet {
1917            records: vec![
1918                relation("obs:1a", "sample:1"),
1919                relation("obs:1b", "sample:1"),
1920                relation("obs:2a", "sample:2"),
1921            ],
1922        };
1923
1924        let aggregated = aggregate_observation_predictions(
1925            &block,
1926            &relations,
1927            &AggregationPolicy::default(),
1928            &[sid("sample:1"), sid("sample:2")],
1929        )
1930        .unwrap();
1931
1932        assert_eq!(
1933            aggregated.sample_ids,
1934            vec![sid("sample:1"), sid("sample:2")]
1935        );
1936        assert_eq!(aggregated.values, vec![vec![2.0], vec![10.0]]);
1937    }
1938
1939    #[test]
1940    fn aggregates_relation_backed_combo_predictions_by_sample() {
1941        let relations = SampleRelationSet {
1942            records: vec![
1943                relation("obs:s1.a", "sample:1"),
1944                relation("obs:s1.b", "sample:1"),
1945                relation("obs:s2.a", "sample:2"),
1946                relation("obs:s2.b", "sample:2"),
1947                combo_relation("obs:s1.combo", "sample:1", &["obs:s1.a", "obs:s1.b"]),
1948                combo_relation("obs:s2.combo", "sample:2", &["obs:s2.a", "obs:s2.b"]),
1949            ],
1950        };
1951        let block = ObservationPredictionBlock {
1952            prediction_id: Some("pred:combo".to_string()),
1953            producer_node: NodeId::new("model:combo").unwrap(),
1954            producer_port: None,
1955            partition: PredictionPartition::Validation,
1956            fold_id: Some(FoldId::new("fold:0").unwrap()),
1957            observation_ids: vec![oid("obs:s1.combo"), oid("obs:s2.combo")],
1958            values: vec![vec![5.0], vec![9.0]],
1959            weights: Vec::new(),
1960            target_names: vec!["y".to_string()],
1961        };
1962
1963        let aggregated = aggregate_observation_predictions(
1964            &block,
1965            &relations,
1966            &AggregationPolicy::default(),
1967            &[sid("sample:1"), sid("sample:2")],
1968        )
1969        .unwrap();
1970
1971        assert_eq!(aggregated.values, vec![vec![5.0], vec![9.0]]);
1972    }
1973
1974    #[test]
1975    fn robust_mean_trims_extreme_repeated_predictions() {
1976        let observations = (0..10)
1977            .map(|idx| format!("obs:s1.{idx}"))
1978            .collect::<Vec<_>>();
1979        let relations = SampleRelationSet {
1980            records: observations
1981                .iter()
1982                .map(|observation| relation(observation, "sample:1"))
1983                .collect(),
1984        };
1985        let block = ObservationPredictionBlock {
1986            prediction_id: Some("pred:robust".to_string()),
1987            producer_node: NodeId::new("model:pls").unwrap(),
1988            producer_port: None,
1989            partition: PredictionPartition::Validation,
1990            fold_id: Some(FoldId::new("fold:0").unwrap()),
1991            observation_ids: observations
1992                .iter()
1993                .map(|observation| oid(observation))
1994                .collect(),
1995            values: vec![
1996                vec![0.0],
1997                vec![1.0],
1998                vec![2.0],
1999                vec![3.0],
2000                vec![4.0],
2001                vec![5.0],
2002                vec![6.0],
2003                vec![7.0],
2004                vec![8.0],
2005                vec![100.0],
2006            ],
2007            weights: Vec::new(),
2008            target_names: vec!["y".to_string()],
2009        };
2010
2011        let aggregated = aggregate_observation_predictions(
2012            &block,
2013            &relations,
2014            &AggregationPolicy {
2015                method: AggregationMethod::RobustMean,
2016                ..AggregationPolicy::default()
2017            },
2018            &[sid("sample:1")],
2019        )
2020        .unwrap();
2021
2022        assert_eq!(aggregated.values, vec![vec![4.5]]);
2023    }
2024
2025    #[test]
2026    fn exclude_outliers_passes_through_single_repetition() {
2027        // A sample with one observation has nothing to gate on: mean-of-one.
2028        let relations = SampleRelationSet {
2029            records: vec![relation("obs:1", "sample:1")],
2030        };
2031        let block = ObservationPredictionBlock {
2032            prediction_id: None,
2033            producer_node: NodeId::new("model:pls").unwrap(),
2034            producer_port: None,
2035            partition: PredictionPartition::Validation,
2036            fold_id: None,
2037            observation_ids: vec![oid("obs:1")],
2038            values: vec![vec![1.0]],
2039            weights: Vec::new(),
2040            target_names: vec!["y".to_string()],
2041        };
2042
2043        let aggregated = aggregate_observation_predictions(
2044            &block,
2045            &relations,
2046            &AggregationPolicy {
2047                method: AggregationMethod::ExcludeOutliers,
2048                ..AggregationPolicy::default()
2049            },
2050            &[sid("sample:1")],
2051        )
2052        .unwrap();
2053
2054        assert_eq!(aggregated.values, vec![vec![1.0]]);
2055    }
2056
2057    #[test]
2058    fn aggregates_repeated_predictions_with_median_vote_and_weights() {
2059        let relations = SampleRelationSet {
2060            records: vec![
2061                relation("obs:1a", "sample:1"),
2062                relation("obs:1b", "sample:1"),
2063                relation("obs:1c", "sample:1"),
2064                relation("obs:2a", "sample:2"),
2065                relation("obs:2b", "sample:2"),
2066            ],
2067        };
2068        let base_block = ObservationPredictionBlock {
2069            prediction_id: Some("pred:oof".to_string()),
2070            producer_node: NodeId::new("model:pls").unwrap(),
2071            producer_port: None,
2072            partition: PredictionPartition::Validation,
2073            fold_id: Some(FoldId::new("fold:0").unwrap()),
2074            observation_ids: vec![
2075                oid("obs:1a"),
2076                oid("obs:1b"),
2077                oid("obs:1c"),
2078                oid("obs:2a"),
2079                oid("obs:2b"),
2080            ],
2081            values: vec![
2082                vec![1.0, 0.0],
2083                vec![5.0, 1.0],
2084                vec![9.0, 1.0],
2085                vec![10.0, 2.0],
2086                vec![30.0, 3.0],
2087            ],
2088            weights: Vec::new(),
2089            target_names: vec!["regression".to_string(), "class".to_string()],
2090        };
2091        let sample_order = [sid("sample:1"), sid("sample:2")];
2092
2093        let median_policy = AggregationPolicy {
2094            method: AggregationMethod::Median,
2095            ..AggregationPolicy::default()
2096        };
2097        let median = aggregate_observation_predictions(
2098            &base_block,
2099            &relations,
2100            &median_policy,
2101            &sample_order,
2102        )
2103        .unwrap();
2104        assert_eq!(median.values, vec![vec![5.0, 1.0], vec![20.0, 2.5]]);
2105
2106        let vote_policy = AggregationPolicy {
2107            method: AggregationMethod::Vote,
2108            ..AggregationPolicy::default()
2109        };
2110        let vote =
2111            aggregate_observation_predictions(&base_block, &relations, &vote_policy, &sample_order)
2112                .unwrap();
2113        assert_eq!(vote.values, vec![vec![1.0, 1.0], vec![10.0, 2.0]]);
2114
2115        let mut weighted_block = base_block;
2116        weighted_block.weights = vec![1.0, 1.0, 2.0, 1.0, 3.0];
2117        let weighted_policy = AggregationPolicy {
2118            method: AggregationMethod::WeightedMean,
2119            weights: AggregationWeights::ControllerEmitted,
2120            ..AggregationPolicy::default()
2121        };
2122        let weighted = aggregate_observation_predictions(
2123            &weighted_block,
2124            &relations,
2125            &weighted_policy,
2126            &sample_order,
2127        )
2128        .unwrap();
2129        assert_eq!(weighted.values, vec![vec![6.0, 0.75], vec![25.0, 2.75]]);
2130    }
2131
2132    #[test]
2133    fn refuses_incompatible_observation_weight_contracts() {
2134        let relations = SampleRelationSet {
2135            records: vec![
2136                relation("obs:1a", "sample:1"),
2137                relation("obs:1b", "sample:1"),
2138            ],
2139        };
2140        let block = ObservationPredictionBlock {
2141            prediction_id: None,
2142            producer_node: NodeId::new("model:pls").unwrap(),
2143            producer_port: None,
2144            partition: PredictionPartition::Validation,
2145            fold_id: None,
2146            observation_ids: vec![oid("obs:1a"), oid("obs:1b")],
2147            values: vec![vec![1.0], vec![2.0]],
2148            weights: vec![1.0, 2.0],
2149            target_names: vec!["y".to_string()],
2150        };
2151
2152        let mean_error = aggregate_observation_predictions(
2153            &block,
2154            &relations,
2155            &AggregationPolicy::default(),
2156            &[sid("sample:1")],
2157        )
2158        .unwrap_err()
2159        .to_string();
2160        assert!(
2161            mean_error.contains("non-weighted aggregation"),
2162            "unexpected mean error: {mean_error}"
2163        );
2164
2165        let mut missing_weights_block = block;
2166        missing_weights_block.weights.clear();
2167        let weighted_error = aggregate_observation_predictions(
2168            &missing_weights_block,
2169            &relations,
2170            &AggregationPolicy {
2171                method: AggregationMethod::WeightedMean,
2172                weights: AggregationWeights::ControllerEmitted,
2173                ..AggregationPolicy::default()
2174            },
2175            &[sid("sample:1")],
2176        )
2177        .unwrap_err()
2178        .to_string();
2179        assert!(
2180            weighted_error.contains("requires one weight per observation"),
2181            "unexpected weighted error: {weighted_error}"
2182        );
2183    }
2184
2185    #[test]
2186    fn aggregates_sample_predictions_to_target_and_group_units() {
2187        let relations = SampleRelationSet {
2188            records: vec![
2189                relation_with_units("obs:s1:a", "sample:1", "target:a", "group:left"),
2190                relation_with_units("obs:s1:b", "sample:1", "target:a", "group:left"),
2191                relation_with_units("obs:s2:a", "sample:2", "target:a", "group:left"),
2192                relation_with_units("obs:s3:a", "sample:3", "target:b", "group:right"),
2193            ],
2194        };
2195        let block = PredictionBlock {
2196            prediction_id: Some("pred:sample".to_string()),
2197            producer_node: NodeId::new("model:pls").unwrap(),
2198            producer_port: None,
2199            partition: PredictionPartition::Validation,
2200            fold_id: Some(FoldId::new("fold:0").unwrap()),
2201            sample_ids: vec![sid("sample:1"), sid("sample:2"), sid("sample:3")],
2202            values: vec![vec![10.0], vec![4.0], vec![30.0]],
2203            target_names: vec!["y".to_string()],
2204        };
2205
2206        let target_policy = AggregationPolicy {
2207            aggregation_level: PredictionLevel::Target,
2208            method: AggregationMethod::Mean,
2209            ..AggregationPolicy::default()
2210        };
2211        let by_target = aggregate_sample_predictions_by_unit(
2212            &block,
2213            &relations,
2214            &target_policy,
2215            &[
2216                PredictionUnitId::Target(TargetId::new("target:a").unwrap()),
2217                PredictionUnitId::Target(TargetId::new("target:b").unwrap()),
2218            ],
2219        )
2220        .unwrap();
2221        assert_eq!(by_target.level, PredictionLevel::Target);
2222        assert_eq!(by_target.values, vec![vec![7.0], vec![30.0]]);
2223
2224        let group_policy = AggregationPolicy {
2225            aggregation_level: PredictionLevel::Group,
2226            method: AggregationMethod::WeightedMean,
2227            weights: AggregationWeights::RepetitionCount,
2228            ..AggregationPolicy::default()
2229        };
2230        let by_group = aggregate_sample_predictions_by_unit(
2231            &block,
2232            &relations,
2233            &group_policy,
2234            &[
2235                PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
2236                PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
2237            ],
2238        )
2239        .unwrap();
2240        assert_eq!(by_group.level, PredictionLevel::Group);
2241        assert_eq!(by_group.values, vec![vec![8.0], vec![30.0]]);
2242    }
2243
2244    #[test]
2245    fn refuses_target_group_aggregation_without_relation_units() {
2246        let relations = SampleRelationSet {
2247            records: vec![SampleRelation::new(oid("obs:1"), sid("sample:1"))],
2248        };
2249        let block = PredictionBlock {
2250            prediction_id: None,
2251            producer_node: NodeId::new("model:pls").unwrap(),
2252            producer_port: None,
2253            partition: PredictionPartition::Validation,
2254            fold_id: None,
2255            sample_ids: vec![sid("sample:1")],
2256            values: vec![vec![1.0]],
2257            target_names: vec!["y".to_string()],
2258        };
2259
2260        let error = aggregate_sample_predictions_by_unit(
2261            &block,
2262            &relations,
2263            &AggregationPolicy {
2264                aggregation_level: PredictionLevel::Target,
2265                method: AggregationMethod::Mean,
2266                ..AggregationPolicy::default()
2267            },
2268            &[PredictionUnitId::Target(
2269                TargetId::new("target:missing").unwrap(),
2270            )],
2271        )
2272        .unwrap_err()
2273        .to_string();
2274        assert!(
2275            error.contains("missing target id"),
2276            "unexpected target aggregation error: {error}"
2277        );
2278    }
2279
2280    #[test]
2281    fn refuses_missing_observation_relation() {
2282        let block = ObservationPredictionBlock {
2283            prediction_id: None,
2284            producer_node: NodeId::new("model:pls").unwrap(),
2285            producer_port: None,
2286            partition: PredictionPartition::Validation,
2287            fold_id: None,
2288            observation_ids: vec![oid("obs:missing")],
2289            values: vec![vec![1.0]],
2290            weights: Vec::new(),
2291            target_names: vec!["y".to_string()],
2292        };
2293
2294        assert!(aggregate_observation_predictions(
2295            &block,
2296            &SampleRelationSet::default(),
2297            &AggregationPolicy::default(),
2298            &[sid("sample:1")]
2299        )
2300        .is_err());
2301    }
2302
2303    #[test]
2304    fn cross_fold_reduction_concats_disjoint_and_averages_shared() {
2305        let node = NodeId::new("model:pls").unwrap();
2306        let block = |fold: &str, rows: &[(&str, f64)]| PredictionBlock {
2307            prediction_id: None,
2308            producer_node: node.clone(),
2309            producer_port: None,
2310            partition: PredictionPartition::Validation,
2311            fold_id: Some(FoldId::new(fold).unwrap()),
2312            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2313            values: rows.iter().map(|(_, v)| vec![*v]).collect(),
2314            target_names: vec!["y".to_string()],
2315        };
2316
2317        // Disjoint folds (the OOF/validation case) -> concat, each sample once, value unchanged.
2318        let oof = reduce_predictions_across_folds(
2319            &[
2320                block("fold0", &[("s1", 1.0), ("s2", 2.0)]),
2321                block("fold1", &[("s3", 3.0), ("s4", 4.0)]),
2322            ],
2323            None,
2324            "avg",
2325        )
2326        .unwrap();
2327        assert_eq!(oof.sample_ids.len(), 4);
2328        assert_eq!(oof.fold_id, Some(FoldId::new("avg").unwrap()));
2329        assert_eq!(oof.values, vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]]);
2330
2331        // Shared folds (the test case, each fold predicts the same samples) -> mean per sample.
2332        let shared = [
2333            block("fold0", &[("t1", 0.0), ("t2", 10.0)]),
2334            block("fold1", &[("t1", 4.0), ("t2", 20.0)]),
2335        ];
2336        let avg = reduce_predictions_across_folds(&shared, None, "avg").unwrap();
2337        assert_eq!(avg.values, vec![vec![2.0], vec![15.0]]);
2338
2339        // w_avg with fold weights [1, 3]: (0*1+4*3)/4=3 ; (10*1+20*3)/4=17.5.
2340        let wavg = reduce_predictions_across_folds(&shared, Some(&[1.0, 3.0]), "w_avg").unwrap();
2341        assert_eq!(wavg.values, vec![vec![3.0], vec![17.5]]);
2342        assert_eq!(wavg.fold_id, Some(FoldId::new("w_avg").unwrap()));
2343
2344        // Mismatched weights are rejected.
2345        assert!(reduce_predictions_across_folds(
2346            &[block("f0", &[("a", 1.0)])],
2347            Some(&[1.0, 2.0]),
2348            "avg"
2349        )
2350        .is_err());
2351    }
2352
2353    #[test]
2354    fn exclude_outliers_drops_hotelling_t2_extreme_repetition() {
2355        // 6 repetitions of one sample; the 100.0 row is a gross outlier and must be
2356        // excluded before the mean. Survivors {1,2,3,4,5} -> mean 3.0.
2357        let observations = (0..6)
2358            .map(|idx| format!("obs:s1.{idx}"))
2359            .collect::<Vec<_>>();
2360        let relations = SampleRelationSet {
2361            records: observations
2362                .iter()
2363                .map(|observation| relation(observation, "sample:1"))
2364                .collect(),
2365        };
2366        let block = ObservationPredictionBlock {
2367            prediction_id: Some("pred:t2".to_string()),
2368            producer_node: NodeId::new("model:pls").unwrap(),
2369            producer_port: None,
2370            partition: PredictionPartition::Validation,
2371            fold_id: Some(FoldId::new("fold:0").unwrap()),
2372            observation_ids: observations
2373                .iter()
2374                .map(|observation| oid(observation))
2375                .collect(),
2376            values: vec![
2377                vec![1.0],
2378                vec![2.0],
2379                vec![3.0],
2380                vec![4.0],
2381                vec![5.0],
2382                vec![100.0],
2383            ],
2384            weights: Vec::new(),
2385            target_names: vec!["y".to_string()],
2386        };
2387
2388        let aggregated = aggregate_observation_predictions(
2389            &block,
2390            &relations,
2391            &AggregationPolicy {
2392                method: AggregationMethod::ExcludeOutliers,
2393                ..AggregationPolicy::default()
2394            },
2395            &[sid("sample:1")],
2396        )
2397        .unwrap();
2398
2399        assert_eq!(aggregated.values, vec![vec![3.0]]);
2400    }
2401
2402    #[test]
2403    fn exclude_outliers_keeps_small_or_constant_repetition_sets() {
2404        // Two repetitions: too few rows to gate on robust spread -> plain mean.
2405        let small_relations = SampleRelationSet {
2406            records: vec![
2407                relation("obs:1a", "sample:1"),
2408                relation("obs:1b", "sample:1"),
2409            ],
2410        };
2411        let small_block = ObservationPredictionBlock {
2412            prediction_id: None,
2413            producer_node: NodeId::new("model:pls").unwrap(),
2414            producer_port: None,
2415            partition: PredictionPartition::Validation,
2416            fold_id: None,
2417            observation_ids: vec![oid("obs:1a"), oid("obs:1b")],
2418            values: vec![vec![1.0], vec![9.0]],
2419            weights: Vec::new(),
2420            target_names: vec!["y".to_string()],
2421        };
2422        let small = aggregate_observation_predictions(
2423            &small_block,
2424            &small_relations,
2425            &AggregationPolicy {
2426                method: AggregationMethod::ExcludeOutliers,
2427                ..AggregationPolicy::default()
2428            },
2429            &[sid("sample:1")],
2430        )
2431        .unwrap();
2432        assert_eq!(small.values, vec![vec![5.0]]);
2433
2434        // Constant target across many repetitions: zero variance, nothing flagged.
2435        let observations = (0..5).map(|idx| format!("obs:c.{idx}")).collect::<Vec<_>>();
2436        let constant_relations = SampleRelationSet {
2437            records: observations
2438                .iter()
2439                .map(|observation| relation(observation, "sample:1"))
2440                .collect(),
2441        };
2442        let constant_block = ObservationPredictionBlock {
2443            prediction_id: None,
2444            producer_node: NodeId::new("model:pls").unwrap(),
2445            producer_port: None,
2446            partition: PredictionPartition::Validation,
2447            fold_id: None,
2448            observation_ids: observations
2449                .iter()
2450                .map(|observation| oid(observation))
2451                .collect(),
2452            values: vec![vec![7.0]; 5],
2453            weights: Vec::new(),
2454            target_names: vec!["y".to_string()],
2455        };
2456        let constant = aggregate_observation_predictions(
2457            &constant_block,
2458            &constant_relations,
2459            &AggregationPolicy {
2460                method: AggregationMethod::ExcludeOutliers,
2461                ..AggregationPolicy::default()
2462            },
2463            &[sid("sample:1")],
2464        )
2465        .unwrap();
2466        assert_eq!(constant.values, vec![vec![7.0]]);
2467    }
2468
2469    #[test]
2470    fn exclude_outliers_aggregates_sample_predictions_to_unit() {
2471        // Sample-to-group path: group:left has a gross outlier sample to exclude.
2472        let relations = SampleRelationSet {
2473            records: vec![
2474                relation_with_units("o:1", "sample:1", "t:a", "group:left"),
2475                relation_with_units("o:2", "sample:2", "t:a", "group:left"),
2476                relation_with_units("o:3", "sample:3", "t:a", "group:left"),
2477                relation_with_units("o:4", "sample:4", "t:a", "group:left"),
2478                relation_with_units("o:5", "sample:5", "t:b", "group:right"),
2479            ],
2480        };
2481        let block = PredictionBlock {
2482            prediction_id: Some("pred:sample".to_string()),
2483            producer_node: NodeId::new("model:pls").unwrap(),
2484            producer_port: None,
2485            partition: PredictionPartition::Validation,
2486            fold_id: Some(FoldId::new("fold:0").unwrap()),
2487            sample_ids: vec![
2488                sid("sample:1"),
2489                sid("sample:2"),
2490                sid("sample:3"),
2491                sid("sample:4"),
2492                sid("sample:5"),
2493            ],
2494            values: vec![vec![1.0], vec![2.0], vec![3.0], vec![100.0], vec![42.0]],
2495            target_names: vec!["y".to_string()],
2496        };
2497
2498        let aggregated = aggregate_sample_predictions_by_unit(
2499            &block,
2500            &relations,
2501            &AggregationPolicy {
2502                aggregation_level: PredictionLevel::Group,
2503                method: AggregationMethod::ExcludeOutliers,
2504                ..AggregationPolicy::default()
2505            },
2506            &[
2507                PredictionUnitId::Group(GroupId::new("group:left").unwrap()),
2508                PredictionUnitId::Group(GroupId::new("group:right").unwrap()),
2509            ],
2510        )
2511        .unwrap();
2512        // group:left survivors {1,2,3} -> 2.0 ; group:right single sample -> 42.0.
2513        assert_eq!(aggregated.values, vec![vec![2.0], vec![42.0]]);
2514    }
2515
2516    #[test]
2517    fn cross_branch_reduction_averages_only_covering_branches() {
2518        let merge = NodeId::new("merge:branch.fusion").unwrap();
2519        let branch = |producer: &str, rows: &[(&str, f64)]| PredictionBlock {
2520            prediction_id: None,
2521            producer_node: NodeId::new(producer).unwrap(),
2522            producer_port: None,
2523            partition: PredictionPartition::Validation,
2524            fold_id: Some(FoldId::new("fold:0").unwrap()),
2525            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2526            values: rows.iter().map(|(_, v)| vec![*v]).collect(),
2527            target_names: vec!["y".to_string()],
2528        };
2529
2530        // Two model-bearing branches with DIFFERENT producers; b1 does not cover s3
2531        // (asymmetric coverage). s1 covered by both -> mean; s3 only by b0 -> b0 value.
2532        let fused = reduce_predictions_across_branches(
2533            &[
2534                branch(
2535                    "branch:b0.model:ridge",
2536                    &[("s1", 10.0), ("s2", 4.0), ("s3", 6.0)],
2537                ),
2538                branch("branch:b1.model:rf", &[("s1", 20.0), ("s2", 8.0)]),
2539            ],
2540            None,
2541            &merge,
2542        )
2543        .unwrap();
2544        assert_eq!(fused.producer_node, merge);
2545        assert_eq!(fused.sample_ids, vec![sid("s1"), sid("s2"), sid("s3")]);
2546        assert_eq!(fused.values, vec![vec![15.0], vec![6.0], vec![6.0]]);
2547
2548        // Per-branch ensemble weights [1, 3]: s1 -> (10*1 + 20*3)/4 = 17.5.
2549        let weighted = reduce_predictions_across_branches(
2550            &[
2551                branch("branch:b0.model:ridge", &[("s1", 10.0)]),
2552                branch("branch:b1.model:rf", &[("s1", 20.0)]),
2553            ],
2554            Some(&[1.0, 3.0]),
2555            &merge,
2556        )
2557        .unwrap();
2558        assert_eq!(weighted.values, vec![vec![17.5]]);
2559
2560        // Empty branch set (every branch modelless) is rejected.
2561        assert!(reduce_predictions_across_branches(&[], None, &merge).is_err());
2562        // Mismatched per-branch weights are rejected.
2563        assert!(reduce_predictions_across_branches(
2564            &[branch("branch:b0", &[("s1", 1.0)])],
2565            Some(&[1.0, 2.0]),
2566            &merge
2567        )
2568        .is_err());
2569        // Branches with mismatched target names are rejected.
2570        let mut other_targets = branch("branch:b1", &[("s1", 1.0)]);
2571        other_targets.target_names = vec!["z".to_string()];
2572        assert!(reduce_predictions_across_branches(
2573            &[branch("branch:b0", &[("s1", 1.0)]), other_targets],
2574            None,
2575            &merge
2576        )
2577        .is_err());
2578    }
2579
2580    #[test]
2581    fn proba_mean_fusion_averages_and_renormalizes_class_probabilities() {
2582        let merge = NodeId::new("merge:proba.fusion").unwrap();
2583        let branch = |producer: &str, rows: &[(&str, [f64; 2])]| PredictionBlock {
2584            prediction_id: None,
2585            producer_node: NodeId::new(producer).unwrap(),
2586            producer_port: None,
2587            partition: PredictionPartition::Validation,
2588            fold_id: None,
2589            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2590            values: rows.iter().map(|(_, p)| p.to_vec()).collect(),
2591            target_names: vec!["neg".to_string(), "pos".to_string()],
2592        };
2593
2594        let fused = reduce_proba_mean_across_branches(
2595            &[
2596                branch(
2597                    "branch:b0.model:lr",
2598                    &[("s1", [0.8, 0.2]), ("s2", [0.4, 0.6])],
2599                ),
2600                branch(
2601                    "branch:b1.model:svc",
2602                    &[("s1", [0.6, 0.4]), ("s2", [0.2, 0.8])],
2603                ),
2604            ],
2605            &merge,
2606        )
2607        .unwrap();
2608        assert_eq!(fused.producer_node, merge);
2609        // s1 -> [(0.8+0.6)/2, (0.2+0.4)/2] = [0.7, 0.3] ; s2 -> [0.3, 0.7].
2610        for (row, expected) in fused.values.iter().zip([[0.7, 0.3], [0.3, 0.7]]) {
2611            for (value, want) in row.iter().zip(expected) {
2612                assert!((value - want).abs() < 1e-12, "got {value}, want {want}");
2613            }
2614            assert!((row.iter().sum::<f64>() - 1.0).abs() < 1e-12);
2615        }
2616
2617        // Asymmetric coverage: only b0 predicts s2 -> its row passes through.
2618        let asymmetric = reduce_proba_mean_across_branches(
2619            &[
2620                branch(
2621                    "branch:b0.model:lr",
2622                    &[("s1", [0.9, 0.1]), ("s2", [0.3, 0.7])],
2623                ),
2624                branch("branch:b1.model:svc", &[("s1", [0.5, 0.5])]),
2625            ],
2626            &merge,
2627        )
2628        .unwrap();
2629        assert_eq!(asymmetric.sample_ids, vec![sid("s1"), sid("s2")]);
2630        assert!((asymmetric.values[1][1] - 0.7).abs() < 1e-12);
2631
2632        // Rows that are not probability vectors are rejected.
2633        assert!(reduce_proba_mean_across_branches(
2634            &[branch("branch:b0", &[("s1", [0.8, 0.4])])],
2635            &merge
2636        )
2637        .is_err());
2638        assert!(reduce_proba_mean_across_branches(
2639            &[branch("branch:b0", &[("s1", [1.2, -0.2])])],
2640            &merge
2641        )
2642        .is_err());
2643        // Empty branch set is rejected.
2644        assert!(reduce_proba_mean_across_branches(&[], &merge).is_err());
2645
2646        // A NaN class probability must be rejected — it would slip past the `< 0.0` and
2647        // `sum != 1` checks (NaN compares false) but is caught by the content gate.
2648        assert!(reduce_proba_mean_across_branches(
2649            &[branch("branch:b0", &[("s1", [f64::NAN, 0.5])])],
2650            &merge
2651        )
2652        .is_err());
2653    }
2654
2655    #[test]
2656    fn cross_fold_reduction_rejects_within_fold_duplicate_and_non_finite() {
2657        let node = NodeId::new("model:pls").unwrap();
2658        let block = |fold: &str, rows: &[(&str, f64)]| PredictionBlock {
2659            prediction_id: None,
2660            producer_node: node.clone(),
2661            producer_port: None,
2662            partition: PredictionPartition::Validation,
2663            fold_id: Some(FoldId::new(fold).unwrap()),
2664            sample_ids: rows.iter().map(|(s, _)| sid(s)).collect(),
2665            values: rows.iter().map(|(_, v)| vec![*v]).collect(),
2666            target_names: vec!["y".to_string()],
2667        };
2668
2669        // A within-fold duplicate sample would be averaged in twice (double-count) — rejected.
2670        let dup = block("fold0", &[("s1", 1.0), ("s1", 3.0)]);
2671        let err = reduce_predictions_across_folds(&[dup], None, "avg").unwrap_err();
2672        assert!(
2673            err.to_string().contains("duplicate prediction"),
2674            "got: {err}"
2675        );
2676
2677        // A non-finite value would poison the fused mean — rejected.
2678        let poisoned = block("fold0", &[("s1", f64::NAN), ("s2", 2.0)]);
2679        let err = reduce_predictions_across_folds(&[poisoned], None, "avg").unwrap_err();
2680        assert!(err.to_string().contains("non-finite"), "got: {err}");
2681    }
2682}