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