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