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