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