1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::aggregation::{
6 reduce_predictions_across_folds, AggregatedPredictionBlock, PredictionUnitId,
7};
8use crate::error::{DagMlError, Result};
9use crate::fold::FoldPartitionMode;
10use crate::ids::{FoldId, NodeId, SampleId, VariantId};
11use crate::oof::{validate_producer_oof_coverage, PredictionBlock, PredictionPartition};
12use crate::policy::PredictionLevel;
13use crate::selection::{CandidateScore, MetricObjective};
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RegressionMetricKind {
18 Mse,
19 Rmse,
20 Mae,
21 R2,
22 Accuracy,
26 BalancedAccuracy,
34}
35
36impl RegressionMetricKind {
37 pub fn name(self) -> &'static str {
38 match self {
39 Self::Mse => "mse",
40 Self::Rmse => "rmse",
41 Self::Mae => "mae",
42 Self::R2 => "r2",
43 Self::Accuracy => "accuracy",
44 Self::BalancedAccuracy => "balanced_accuracy",
45 }
46 }
47
48 pub fn objective(self) -> MetricObjective {
49 match self {
50 Self::Mse | Self::Rmse | Self::Mae => MetricObjective::Minimize,
51 Self::R2 | Self::Accuracy | Self::BalancedAccuracy => MetricObjective::Maximize,
52 }
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
57pub struct RegressionTargetBlock {
58 pub level: PredictionLevel,
59 pub unit_ids: Vec<PredictionUnitId>,
60 pub values: Vec<Vec<f64>>,
61 #[serde(default)]
62 pub target_names: Vec<String>,
63}
64
65impl RegressionTargetBlock {
66 pub fn validate_shape(&self) -> Result<usize> {
67 if self.unit_ids.len() != self.values.len() {
68 return Err(DagMlError::OofValidation(format!(
69 "target block has {} unit ids but {} target rows",
70 self.unit_ids.len(),
71 self.values.len()
72 )));
73 }
74 if self
75 .unit_ids
76 .iter()
77 .any(|unit_id| unit_id.level() != self.level)
78 {
79 return Err(DagMlError::OofValidation(format!(
80 "target block contains units outside level {:?}",
81 self.level
82 )));
83 }
84 let unique = self.unit_ids.iter().collect::<BTreeSet<_>>();
85 if unique.len() != self.unit_ids.len() {
86 return Err(DagMlError::OofValidation(
87 "target block contains duplicate unit ids".to_string(),
88 ));
89 }
90 let width = self.values.first().map_or(0, Vec::len);
91 if width == 0 {
92 return Err(DagMlError::OofValidation(
93 "target block has empty target rows".to_string(),
94 ));
95 }
96 if self.values.iter().any(|row| row.len() != width) {
97 return Err(DagMlError::OofValidation(
98 "target block has ragged target rows".to_string(),
99 ));
100 }
101 if self.values.iter().flatten().any(|value| !value.is_finite()) {
102 return Err(DagMlError::OofValidation(
103 "target block contains non-finite values".to_string(),
104 ));
105 }
106 if !self.target_names.is_empty() && self.target_names.len() != width {
107 return Err(DagMlError::OofValidation(format!(
108 "target block has {} target names for width {}",
109 self.target_names.len(),
110 width
111 )));
112 }
113 Ok(width)
114 }
115}
116
117pub fn reassemble_merge_targets(
141 producer_node: &NodeId,
142 merge_sample_ids: &[SampleId],
143 by_sample_target: &mut BTreeMap<SampleId, Vec<f64>>,
144 target_names: Vec<String>,
145) -> Result<Option<RegressionTargetBlock>> {
146 if by_sample_target.is_empty() {
147 return Ok(None);
148 }
149 let missing: Vec<String> = merge_sample_ids
150 .iter()
151 .filter(|sample_id| !by_sample_target.contains_key(*sample_id))
152 .map(ToString::to_string)
153 .collect();
154 if !missing.is_empty() {
155 return Err(DagMlError::OofValidation(format!(
156 "merge node `{producer_node}` has partial target coverage: {} of {} merged sample(s) lack a y_true row ({}) while other contributing branch(es) emitted targets — a merge that some branch scores must have COMPLETE target coverage across the merge universe, never a silent no-score",
157 missing.len(),
158 merge_sample_ids.len(),
159 missing.join(", ")
160 )));
161 }
162 let values: Vec<Vec<f64>> = merge_sample_ids
163 .iter()
164 .map(|sample_id| {
165 by_sample_target
166 .remove(sample_id)
167 .expect("target coverage was just verified complete")
168 })
169 .collect();
170 Ok(Some(RegressionTargetBlock {
171 level: PredictionLevel::Sample,
172 unit_ids: merge_sample_ids
173 .iter()
174 .cloned()
175 .map(PredictionUnitId::Sample)
176 .collect(),
177 values,
178 target_names,
179 }))
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
183pub struct RegressionMetricReport {
184 #[serde(default)]
185 pub prediction_id: Option<String>,
186 pub producer_node: NodeId,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub variant_id: Option<VariantId>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub variant_label: Option<String>,
201 pub partition: PredictionPartition,
202 pub fold_id: Option<FoldId>,
203 pub level: PredictionLevel,
204 pub row_count: usize,
205 pub target_width: usize,
206 #[serde(default)]
207 pub target_names: Vec<String>,
208 pub metrics: BTreeMap<String, f64>,
209}
210
211impl RegressionMetricReport {
212 pub fn validate(&self) -> Result<()> {
213 if self.row_count == 0 {
214 return Err(DagMlError::OofValidation(
215 "regression metric report has zero rows".to_string(),
216 ));
217 }
218 if self.target_width == 0 {
219 return Err(DagMlError::OofValidation(
220 "regression metric report has zero target width".to_string(),
221 ));
222 }
223 if !self.target_names.is_empty() && self.target_names.len() != self.target_width {
224 return Err(DagMlError::OofValidation(format!(
225 "regression metric report has {} target names for width {}",
226 self.target_names.len(),
227 self.target_width
228 )));
229 }
230 if self.metrics.is_empty() {
231 return Err(DagMlError::OofValidation(
232 "regression metric report has no metrics".to_string(),
233 ));
234 }
235 for (name, value) in &self.metrics {
236 if name.trim().is_empty() {
237 return Err(DagMlError::OofValidation(
238 "regression metric report contains an empty metric name".to_string(),
239 ));
240 }
241 if !value.is_finite() {
242 return Err(DagMlError::OofValidation(format!(
243 "regression metric `{name}` is not finite"
244 )));
245 }
246 }
247 Ok(())
248 }
249
250 pub fn into_candidate_score(self, candidate_id: impl Into<String>) -> Result<CandidateScore> {
251 self.validate()?;
252 let mut metadata = BTreeMap::from([
253 (
254 "producer_node".to_string(),
255 serde_json::json!(self.producer_node),
256 ),
257 ("partition".to_string(), serde_json::json!(self.partition)),
258 (
259 "metric_level".to_string(),
260 serde_json::json!(prediction_level_name(self.level)),
261 ),
262 ("row_count".to_string(), serde_json::json!(self.row_count)),
263 (
264 "target_width".to_string(),
265 serde_json::json!(self.target_width),
266 ),
267 ]);
268 if let Some(prediction_id) = self.prediction_id {
269 metadata.insert(
270 "prediction_id".to_string(),
271 serde_json::json!(prediction_id),
272 );
273 }
274 if let Some(fold_id) = self.fold_id {
275 metadata.insert("fold_id".to_string(), serde_json::json!(fold_id));
276 }
277 if let Some(variant_id) = self.variant_id {
278 metadata.insert("variant_id".to_string(), serde_json::json!(variant_id));
279 }
280 if !self.target_names.is_empty() {
281 metadata.insert(
282 "target_names".to_string(),
283 serde_json::json!(self.target_names),
284 );
285 }
286 let score = CandidateScore {
287 candidate_id: candidate_id.into(),
288 metrics: self.metrics,
289 metadata,
290 };
291 score.validate()?;
292 Ok(score)
293 }
294}
295
296pub fn regression_report_to_candidate_score(
297 candidate_id: impl Into<String>,
298 report: RegressionMetricReport,
299) -> Result<CandidateScore> {
300 report.into_candidate_score(candidate_id)
301}
302
303pub fn score_regression_prediction_block(
304 predictions: &PredictionBlock,
305 targets: &RegressionTargetBlock,
306 metrics: &[RegressionMetricKind],
307) -> Result<RegressionMetricReport> {
308 let width = validate_sample_prediction_block(predictions)?;
309 let prediction_units = predictions
310 .sample_ids
311 .iter()
312 .cloned()
313 .map(PredictionUnitId::Sample)
314 .collect::<Vec<_>>();
315 score_regression_rows(
316 PredictionRows {
317 level: PredictionLevel::Sample,
318 unit_ids: &prediction_units,
319 values: &predictions.values,
320 target_names: &predictions.target_names,
321 width,
322 origin: PredictionReportOrigin {
323 prediction_id: predictions.prediction_id.clone(),
324 producer_node: predictions.producer_node.clone(),
325 partition: predictions.partition.clone(),
326 fold_id: predictions.fold_id.clone(),
327 },
328 },
329 targets,
330 metrics,
331 )
332}
333
334pub fn score_regression_aggregated_block(
335 predictions: &AggregatedPredictionBlock,
336 targets: &RegressionTargetBlock,
337 metrics: &[RegressionMetricKind],
338) -> Result<RegressionMetricReport> {
339 let width = predictions.validate_shape()?;
340 score_regression_rows(
341 PredictionRows {
342 level: predictions.level,
343 unit_ids: &predictions.unit_ids,
344 values: &predictions.values,
345 target_names: &predictions.target_names,
346 width,
347 origin: PredictionReportOrigin {
348 prediction_id: predictions.prediction_id.clone(),
349 producer_node: predictions.producer_node.clone(),
350 partition: predictions.partition.clone(),
351 fold_id: predictions.fold_id.clone(),
352 },
353 },
354 targets,
355 metrics,
356 )
357}
358
359pub const SCORE_SET_SCHEMA_VERSION: u32 = 1;
361
362fn default_score_set_schema_version() -> u32 {
363 SCORE_SET_SCHEMA_VERSION
364}
365
366type ScoreReportKey = (
376 NodeId,
377 Option<VariantId>,
378 PredictionPartition,
379 Option<FoldId>,
380 PredictionLevel,
381);
382
383#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
384pub struct ScoreSet {
385 #[serde(default = "default_score_set_schema_version")]
386 pub schema_version: u32,
387 pub plan_id: String,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub selection_metric: Option<String>,
391 pub reports: Vec<RegressionMetricReport>,
392}
393
394impl ScoreSet {
395 pub fn validate(&self) -> Result<()> {
397 if self.schema_version == 0 || self.schema_version > SCORE_SET_SCHEMA_VERSION {
398 return Err(DagMlError::OofValidation(format!(
399 "score set schema version {} is unsupported (current {SCORE_SET_SCHEMA_VERSION})",
400 self.schema_version
401 )));
402 }
403 if self.plan_id.trim().is_empty() {
404 return Err(DagMlError::OofValidation(
405 "score set has an empty plan_id".to_string(),
406 ));
407 }
408 let mut seen: BTreeSet<ScoreReportKey> = BTreeSet::new();
409 for report in &self.reports {
410 report.validate()?;
411 let key = (
412 report.producer_node.clone(),
413 report.variant_id.clone(),
414 report.partition.clone(),
415 report.fold_id.clone(),
416 report.level,
417 );
418 if !seen.insert(key) {
419 return Err(DagMlError::OofValidation(format!(
420 "score set has a duplicate report for node `{}` partition {:?} fold {:?} level {:?}",
421 report.producer_node, report.partition, report.fold_id, report.level
422 )));
423 }
424 }
425 Ok(())
426 }
427}
428
429#[derive(Clone, Debug)]
430struct PredictionReportOrigin {
431 prediction_id: Option<String>,
432 producer_node: NodeId,
433 partition: PredictionPartition,
434 fold_id: Option<FoldId>,
435}
436
437#[derive(Clone, Debug)]
438struct PredictionRows<'a> {
439 level: PredictionLevel,
440 unit_ids: &'a [PredictionUnitId],
441 values: &'a [Vec<f64>],
442 target_names: &'a [String],
443 width: usize,
444 origin: PredictionReportOrigin,
445}
446
447fn score_regression_rows(
448 predictions: PredictionRows<'_>,
449 targets: &RegressionTargetBlock,
450 metrics: &[RegressionMetricKind],
451) -> Result<RegressionMetricReport> {
452 if metrics.is_empty() {
453 return Err(DagMlError::OofValidation(
454 "no regression metrics requested".to_string(),
455 ));
456 }
457 let mut requested_metrics = BTreeSet::new();
458 for metric in metrics {
459 if !requested_metrics.insert(*metric) {
460 return Err(DagMlError::OofValidation(format!(
461 "duplicate regression metric `{}` requested",
462 metric.name()
463 )));
464 }
465 }
466
467 let target_width = targets.validate_shape()?;
468 if predictions.width != target_width {
469 return Err(DagMlError::OofValidation(format!(
470 "prediction width {} does not match target width {target_width}",
471 predictions.width
472 )));
473 }
474 if predictions.level != targets.level {
475 return Err(DagMlError::OofValidation(format!(
476 "prediction level {:?} does not match target level {:?}",
477 predictions.level, targets.level
478 )));
479 }
480 if !predictions.target_names.is_empty()
481 && !targets.target_names.is_empty()
482 && predictions.target_names != targets.target_names
483 {
484 return Err(DagMlError::OofValidation(
485 "prediction target names do not match target block names".to_string(),
486 ));
487 }
488
489 let target_by_unit = targets
490 .unit_ids
491 .iter()
492 .zip(targets.values.iter().map(Vec::as_slice))
493 .collect::<BTreeMap<_, _>>();
494 let mut aligned_predictions = Vec::with_capacity(predictions.unit_ids.len());
495 let mut aligned_targets = Vec::with_capacity(predictions.unit_ids.len());
496 for (unit_id, prediction_row) in predictions.unit_ids.iter().zip(predictions.values.iter()) {
497 let target_row = target_by_unit.get(unit_id).ok_or_else(|| {
498 DagMlError::OofValidation(format!(
499 "prediction unit `{unit_id}` is missing from target block"
500 ))
501 })?;
502 aligned_predictions.push(prediction_row.as_slice());
503 aligned_targets.push(*target_row);
504 }
505 if aligned_predictions.len() != target_by_unit.len() {
506 return Err(DagMlError::OofValidation(
507 "target block contains units not present in predictions".to_string(),
508 ));
509 }
510
511 let target_names = if !predictions.target_names.is_empty() {
512 predictions.target_names.to_vec()
513 } else {
514 targets.target_names.clone()
515 };
516 let metric_suffixes = target_metric_names(predictions.width, &target_names);
517 let mut values = BTreeMap::new();
518 for metric in metrics {
519 let per_target = compute_metric_per_target(
520 *metric,
521 predictions.width,
522 &aligned_predictions,
523 &aligned_targets,
524 );
525 values.insert(metric.name().to_string(), macro_mean(&per_target));
526 for (name, value) in metric_suffixes.iter().zip(per_target) {
527 values.insert(format!("{}:{name}", metric.name()), value);
528 }
529 }
530
531 let report = RegressionMetricReport {
532 prediction_id: predictions.origin.prediction_id,
533 producer_node: predictions.origin.producer_node,
534 variant_id: None,
535 variant_label: None,
536 partition: predictions.origin.partition,
537 fold_id: predictions.origin.fold_id,
538 level: predictions.level,
539 row_count: predictions.unit_ids.len(),
540 target_width: predictions.width,
541 target_names,
542 metrics: values,
543 };
544 report.validate()?;
545 Ok(report)
546}
547
548fn validate_sample_prediction_block(block: &PredictionBlock) -> Result<usize> {
549 block.validate_content()
550}
551
552fn compute_metric_per_target(
553 metric: RegressionMetricKind,
554 width: usize,
555 predictions: &[&[f64]],
556 targets: &[&[f64]],
557) -> Vec<f64> {
558 (0..width)
559 .map(|target_idx| match metric {
560 RegressionMetricKind::Mse => {
561 predictions
562 .iter()
563 .zip(targets.iter())
564 .map(|(prediction, target)| {
565 let error = prediction[target_idx] - target[target_idx];
566 error * error
567 })
568 .sum::<f64>()
569 / predictions.len() as f64
570 }
571 RegressionMetricKind::Rmse => (predictions
572 .iter()
573 .zip(targets.iter())
574 .map(|(prediction, target)| {
575 let error = prediction[target_idx] - target[target_idx];
576 error * error
577 })
578 .sum::<f64>()
579 / predictions.len() as f64)
580 .sqrt(),
581 RegressionMetricKind::Mae => {
582 predictions
583 .iter()
584 .zip(targets.iter())
585 .map(|(prediction, target)| (prediction[target_idx] - target[target_idx]).abs())
586 .sum::<f64>()
587 / predictions.len() as f64
588 }
589 RegressionMetricKind::R2 => r2_for_target(target_idx, predictions, targets),
590 RegressionMetricKind::Accuracy => {
591 predictions
592 .iter()
593 .zip(targets.iter())
594 .filter(|(prediction, target)| {
595 (prediction[target_idx] - target[target_idx]).abs() < 0.5
596 })
597 .count() as f64
598 / predictions.len() as f64
599 }
600 RegressionMetricKind::BalancedAccuracy => {
601 balanced_accuracy_for_target(target_idx, predictions, targets)
602 }
603 })
604 .collect()
605}
606
607fn balanced_accuracy_for_target(
614 target_idx: usize,
615 predictions: &[&[f64]],
616 targets: &[&[f64]],
617) -> f64 {
618 let mut per_class: BTreeMap<i64, (usize, usize)> = BTreeMap::new();
620 for (prediction, target) in predictions.iter().zip(targets.iter()) {
621 let true_value = target[target_idx];
622 let class = true_value.round() as i64;
623 let entry = per_class.entry(class).or_insert((0, 0));
624 entry.1 += 1;
625 if (prediction[target_idx] - true_value).abs() < 0.5 {
626 entry.0 += 1;
627 }
628 }
629 if per_class.is_empty() {
630 return 0.0;
631 }
632 let recall_sum: f64 = per_class
633 .values()
634 .map(|(correct, count)| *correct as f64 / *count as f64)
635 .sum();
636 recall_sum / per_class.len() as f64
637}
638
639fn r2_for_target(target_idx: usize, predictions: &[&[f64]], targets: &[&[f64]]) -> f64 {
640 let mean = targets.iter().map(|row| row[target_idx]).sum::<f64>() / targets.len() as f64;
641 let ss_res = predictions
642 .iter()
643 .zip(targets.iter())
644 .map(|(prediction, target)| {
645 let error = prediction[target_idx] - target[target_idx];
646 error * error
647 })
648 .sum::<f64>();
649 let ss_tot = targets
650 .iter()
651 .map(|target| {
652 let centered = target[target_idx] - mean;
653 centered * centered
654 })
655 .sum::<f64>();
656 if ss_tot == 0.0 {
657 if ss_res == 0.0 {
658 1.0
659 } else {
660 0.0
661 }
662 } else {
663 1.0 - ss_res / ss_tot
664 }
665}
666
667fn macro_mean(values: &[f64]) -> f64 {
668 values.iter().sum::<f64>() / values.len() as f64
669}
670
671fn target_metric_names(width: usize, target_names: &[String]) -> Vec<String> {
672 if target_names.is_empty() {
673 (0..width).map(|idx| format!("target_{idx}")).collect()
674 } else {
675 target_names.to_vec()
676 }
677}
678
679fn prediction_level_name(level: PredictionLevel) -> &'static str {
680 match level {
681 PredictionLevel::Observation => "observation",
682 PredictionLevel::Sample => "sample",
683 PredictionLevel::Target => "target",
684 PredictionLevel::Group => "group",
685 }
686}
687
688#[derive(Clone, Debug, PartialEq)]
691pub struct RegressionTargetRecord {
692 pub producer_node: NodeId,
693 pub variant_id: Option<VariantId>,
696 pub partition: PredictionPartition,
697 pub fold_id: Option<FoldId>,
698 pub block: RegressionTargetBlock,
699}
700
701fn combine_validation_targets(
710 producer: &NodeId,
711 records: &[RegressionTargetRecord],
712) -> Result<RegressionTargetBlock> {
713 let mut seen: BTreeMap<PredictionUnitId, Vec<f64>> = BTreeMap::new();
714 let mut unit_ids = Vec::new();
715 let mut values = Vec::new();
716 let mut target_names = Vec::new();
717 for record in records {
718 if &record.producer_node != producer || record.partition != PredictionPartition::Validation
719 {
720 continue;
721 }
722 if target_names.is_empty() {
723 target_names = record.block.target_names.clone();
724 }
725 for (unit_id, row) in record.block.unit_ids.iter().zip(&record.block.values) {
726 match seen.get(unit_id) {
727 None => {
728 seen.insert(unit_id.clone(), row.clone());
729 unit_ids.push(unit_id.clone());
730 values.push(row.clone());
731 }
732 Some(existing) if existing != row => {
733 return Err(DagMlError::OofValidation(format!(
734 "producer `{producer}` has conflicting ground truth for unit `{unit_id:?}` across validation records — the y_true reference is mixed (e.g. several variants in one context); refusing to score against a corrupted reference"
735 )));
736 }
737 Some(_) => {}
738 }
739 }
740 }
741 Ok(RegressionTargetBlock {
742 level: PredictionLevel::Sample,
743 unit_ids,
744 values,
745 target_names,
746 })
747}
748
749#[derive(Clone, Debug, PartialEq)]
758pub struct OofAverageBlock {
759 pub predictions: AggregatedPredictionBlock,
760 pub y_true: RegressionTargetBlock,
761}
762
763#[derive(Clone, Debug, Default, PartialEq)]
768pub struct CrossFoldValidation {
769 pub reports: Vec<RegressionMetricReport>,
770 pub oof_averages: Vec<OofAverageBlock>,
771}
772
773pub fn cross_fold_validation_reports(
786 prediction_blocks: &[PredictionBlock],
787 target_records: &[RegressionTargetRecord],
788 metrics: &[RegressionMetricKind],
789 partition_mode: FoldPartitionMode,
790) -> Result<CrossFoldValidation> {
791 let mut producers: Vec<NodeId> = Vec::new();
792 let mut by_producer: BTreeMap<NodeId, Vec<PredictionBlock>> = BTreeMap::new();
793 for block in prediction_blocks {
794 if block.partition != PredictionPartition::Validation {
795 continue;
796 }
797 if !by_producer.contains_key(&block.producer_node) {
798 producers.push(block.producer_node.clone());
799 }
800 by_producer
801 .entry(block.producer_node.clone())
802 .or_default()
803 .push(block.clone());
804 }
805 let mut reports = Vec::new();
806 let mut oof_averages = Vec::new();
807 for producer in &producers {
808 let blocks = &by_producer[producer];
809 if blocks.len() < 2 {
810 continue;
811 }
812 let block_refs = blocks.iter().collect::<Vec<_>>();
822 validate_producer_oof_coverage(producer, &block_refs, partition_mode, None)?;
823 let targets = combine_validation_targets(producer, target_records)?;
824 if targets.unit_ids.is_empty() {
825 continue;
827 }
828 let average = reduce_predictions_across_folds(blocks, None, "avg")?;
829 reports.push(score_regression_prediction_block(
833 &average, &targets, metrics,
834 )?);
835 oof_averages.push(oof_average_block(&average, &targets));
836 }
837 Ok(CrossFoldValidation {
838 reports,
839 oof_averages,
840 })
841}
842
843fn oof_average_block(
851 average: &PredictionBlock,
852 targets: &RegressionTargetBlock,
853) -> OofAverageBlock {
854 let unit_ids: Vec<PredictionUnitId> = average
855 .sample_ids
856 .iter()
857 .cloned()
858 .map(PredictionUnitId::Sample)
859 .collect();
860 let predictions = AggregatedPredictionBlock {
861 prediction_id: None,
862 producer_node: average.producer_node.clone(),
863 partition: average.partition.clone(),
864 fold_id: average.fold_id.clone(),
865 level: PredictionLevel::Sample,
866 unit_ids: unit_ids.clone(),
867 values: average.values.clone(),
868 target_names: average.target_names.clone(),
869 };
870 let target_by_unit: BTreeMap<&PredictionUnitId, &Vec<f64>> =
871 targets.unit_ids.iter().zip(&targets.values).collect();
872 let y_true = RegressionTargetBlock {
873 level: PredictionLevel::Sample,
874 unit_ids: unit_ids.clone(),
875 values: unit_ids
876 .iter()
877 .map(|unit_id| target_by_unit[unit_id].clone())
878 .collect(),
879 target_names: targets.target_names.clone(),
880 };
881 OofAverageBlock {
882 predictions,
883 y_true,
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use crate::ids::{FoldId, GroupId, NodeId, SampleId, TargetId};
891 use crate::oof::PredictionPartition;
892
893 fn sid(value: &str) -> SampleId {
894 SampleId::new(value).unwrap()
895 }
896
897 fn sample_unit(value: &str) -> PredictionUnitId {
898 PredictionUnitId::Sample(sid(value))
899 }
900
901 fn target_unit(value: &str) -> PredictionUnitId {
902 PredictionUnitId::Target(TargetId::new(value).unwrap())
903 }
904
905 fn group_unit(value: &str) -> PredictionUnitId {
906 PredictionUnitId::Group(GroupId::new(value).unwrap())
907 }
908
909 fn assert_close(left: f64, right: f64) {
910 assert!((left - right).abs() < 1e-12, "expected {right}, got {left}");
911 }
912
913 #[test]
914 fn metric_objectives_match_selection_direction() {
915 assert_eq!(
916 RegressionMetricKind::Rmse.objective(),
917 MetricObjective::Minimize
918 );
919 assert_eq!(
920 RegressionMetricKind::Mae.objective(),
921 MetricObjective::Minimize
922 );
923 assert_eq!(
924 RegressionMetricKind::Mse.objective(),
925 MetricObjective::Minimize
926 );
927 assert_eq!(
928 RegressionMetricKind::R2.objective(),
929 MetricObjective::Maximize
930 );
931 }
932
933 #[test]
934 fn reassemble_merge_targets_empty_map_is_unscored_none() {
935 let producer = NodeId::new("merge:m").unwrap();
937 let mut by_sample: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
938 let block = reassemble_merge_targets(
939 &producer,
940 &[sid("s1"), sid("s2")],
941 &mut by_sample,
942 vec!["y".to_string()],
943 )
944 .unwrap();
945 assert!(
946 block.is_none(),
947 "empty targets -> unscored None, not an error"
948 );
949 }
950
951 #[test]
952 fn reassemble_merge_targets_complete_coverage_emits_ordered_block() {
953 let producer = NodeId::new("merge:m").unwrap();
954 let mut by_sample: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
955 by_sample.insert(sid("s2"), vec![20.0]);
956 by_sample.insert(sid("s1"), vec![10.0]);
957 let block = reassemble_merge_targets(
958 &producer,
959 &[sid("s1"), sid("s2")],
960 &mut by_sample,
961 vec!["y".to_string()],
962 )
963 .unwrap()
964 .expect("complete coverage -> a target block");
965 assert_eq!(
967 block.unit_ids,
968 vec![sample_unit("s1"), sample_unit("s2")],
969 "targets follow the merge sample order"
970 );
971 assert_eq!(block.values, vec![vec![10.0], vec![20.0]]);
972 assert_eq!(block.level, PredictionLevel::Sample);
973 block.validate_shape().unwrap();
974 }
975
976 #[test]
977 fn reassemble_merge_targets_partial_coverage_is_validation_error() {
978 let producer = NodeId::new("merge:m").unwrap();
982 let mut by_sample: BTreeMap<SampleId, Vec<f64>> = BTreeMap::new();
983 by_sample.insert(sid("s1"), vec![10.0]);
984 let err = reassemble_merge_targets(
985 &producer,
986 &[sid("s1"), sid("s2")],
987 &mut by_sample,
988 vec!["y".to_string()],
989 )
990 .unwrap_err();
991 let msg = err.to_string();
992 assert!(
993 msg.contains("partial target coverage") && msg.contains("s2"),
994 "partial coverage names the missing sample: {msg}"
995 );
996 }
997
998 #[test]
999 fn scores_sample_predictions_and_exports_candidate_metrics() {
1000 let predictions = PredictionBlock {
1001 prediction_id: Some("pred:sample".to_string()),
1002 producer_node: NodeId::new("model:pls").unwrap(),
1003 partition: PredictionPartition::Validation,
1004 fold_id: None,
1005 sample_ids: vec![sid("sample:1"), sid("sample:2")],
1006 values: vec![vec![2.0], vec![4.0]],
1007 target_names: vec!["y".to_string()],
1008 };
1009 let targets = RegressionTargetBlock {
1010 level: PredictionLevel::Sample,
1011 unit_ids: vec![sample_unit("sample:2"), sample_unit("sample:1")],
1012 values: vec![vec![5.0], vec![1.0]],
1013 target_names: vec!["y".to_string()],
1014 };
1015
1016 let report = score_regression_prediction_block(
1017 &predictions,
1018 &targets,
1019 &[
1020 RegressionMetricKind::Rmse,
1021 RegressionMetricKind::Mae,
1022 RegressionMetricKind::R2,
1023 ],
1024 )
1025 .unwrap();
1026
1027 assert_eq!(report.level, PredictionLevel::Sample);
1028 assert_close(report.metrics["rmse"], 1.0);
1029 assert_close(report.metrics["rmse:y"], 1.0);
1030 assert_close(report.metrics["mae"], 1.0);
1031 assert_close(report.metrics["r2"], 0.75);
1032 let candidate = regression_report_to_candidate_score("model:pls", report).unwrap();
1033 assert_eq!(candidate.metrics["rmse"], 1.0);
1034 assert_eq!(candidate.metadata["metric_level"], "sample");
1035 assert_eq!(candidate.metadata["producer_node"], "model:pls");
1036 assert_eq!(candidate.metadata["partition"], "validation");
1037 assert_eq!(candidate.metadata["prediction_id"], "pred:sample");
1038 assert_eq!(candidate.metadata["target_names"], serde_json::json!(["y"]));
1039 }
1040
1041 #[test]
1042 fn scores_target_and_group_prediction_blocks() {
1043 let predictions = AggregatedPredictionBlock {
1044 prediction_id: Some("pred:target".to_string()),
1045 producer_node: NodeId::new("model:pls").unwrap(),
1046 partition: PredictionPartition::Validation,
1047 fold_id: None,
1048 level: PredictionLevel::Target,
1049 unit_ids: vec![target_unit("target:a"), target_unit("target:b")],
1050 values: vec![vec![1.0, 10.0], vec![3.0, 30.0]],
1051 target_names: vec!["y1".to_string(), "y2".to_string()],
1052 };
1053 let targets = RegressionTargetBlock {
1054 level: PredictionLevel::Target,
1055 unit_ids: vec![target_unit("target:b"), target_unit("target:a")],
1056 values: vec![vec![2.0, 28.0], vec![2.0, 12.0]],
1057 target_names: vec!["y1".to_string(), "y2".to_string()],
1058 };
1059 let report = score_regression_aggregated_block(
1060 &predictions,
1061 &targets,
1062 &[RegressionMetricKind::Mse, RegressionMetricKind::Rmse],
1063 )
1064 .unwrap();
1065
1066 assert_eq!(report.level, PredictionLevel::Target);
1067 assert_close(report.metrics["mse:y1"], 1.0);
1068 assert_close(report.metrics["mse:y2"], 4.0);
1069 assert_close(report.metrics["mse"], 2.5);
1070 assert_close(report.metrics["rmse:y1"], 1.0);
1071 assert_close(report.metrics["rmse:y2"], 2.0);
1072 assert_close(report.metrics["rmse"], 1.5);
1073
1074 let group_predictions = AggregatedPredictionBlock {
1075 prediction_id: Some("pred:group".to_string()),
1076 producer_node: NodeId::new("model:pls").unwrap(),
1077 partition: PredictionPartition::Validation,
1078 fold_id: None,
1079 level: PredictionLevel::Group,
1080 unit_ids: vec![group_unit("group:a")],
1081 values: vec![vec![3.0]],
1082 target_names: vec!["y".to_string()],
1083 };
1084 let group_targets = RegressionTargetBlock {
1085 level: PredictionLevel::Group,
1086 unit_ids: vec![group_unit("group:a")],
1087 values: vec![vec![1.0]],
1088 target_names: vec!["y".to_string()],
1089 };
1090 let group_report = score_regression_aggregated_block(
1091 &group_predictions,
1092 &group_targets,
1093 &[RegressionMetricKind::Mae],
1094 )
1095 .unwrap();
1096 assert_eq!(group_report.level, PredictionLevel::Group);
1097 assert_close(group_report.metrics["mae"], 2.0);
1098 }
1099
1100 #[test]
1101 fn refuses_metric_alignment_and_contract_mismatches() {
1102 let predictions = AggregatedPredictionBlock {
1103 prediction_id: None,
1104 producer_node: NodeId::new("model:pls").unwrap(),
1105 partition: PredictionPartition::Validation,
1106 fold_id: None,
1107 level: PredictionLevel::Target,
1108 unit_ids: vec![target_unit("target:a")],
1109 values: vec![vec![1.0]],
1110 target_names: vec!["y".to_string()],
1111 };
1112 let missing_target = RegressionTargetBlock {
1113 level: PredictionLevel::Target,
1114 unit_ids: vec![target_unit("target:b")],
1115 values: vec![vec![1.0]],
1116 target_names: vec!["y".to_string()],
1117 };
1118 assert!(score_regression_aggregated_block(
1119 &predictions,
1120 &missing_target,
1121 &[RegressionMetricKind::Rmse],
1122 )
1123 .is_err());
1124
1125 let wrong_level = RegressionTargetBlock {
1126 level: PredictionLevel::Group,
1127 unit_ids: vec![group_unit("group:a")],
1128 values: vec![vec![1.0]],
1129 target_names: vec!["y".to_string()],
1130 };
1131 assert!(score_regression_aggregated_block(
1132 &predictions,
1133 &wrong_level,
1134 &[RegressionMetricKind::Rmse],
1135 )
1136 .is_err());
1137
1138 assert!(score_regression_aggregated_block(&predictions, &missing_target, &[]).is_err());
1139 assert!(score_regression_aggregated_block(
1140 &predictions,
1141 &RegressionTargetBlock {
1142 level: PredictionLevel::Target,
1143 unit_ids: vec![target_unit("target:a")],
1144 values: vec![vec![1.0]],
1145 target_names: vec!["other".to_string()],
1146 },
1147 &[RegressionMetricKind::Rmse],
1148 )
1149 .is_err());
1150 assert!(score_regression_aggregated_block(
1151 &predictions,
1152 &RegressionTargetBlock {
1153 level: PredictionLevel::Target,
1154 unit_ids: vec![target_unit("target:a")],
1155 values: vec![vec![1.0]],
1156 target_names: vec!["y".to_string()],
1157 },
1158 &[RegressionMetricKind::Rmse, RegressionMetricKind::Rmse],
1159 )
1160 .is_err());
1161 }
1162
1163 #[test]
1164 fn refuses_duplicate_and_non_finite_sample_predictions() {
1165 let targets = RegressionTargetBlock {
1166 level: PredictionLevel::Sample,
1167 unit_ids: vec![sample_unit("sample:1")],
1168 values: vec![vec![1.0]],
1169 target_names: vec!["y".to_string()],
1170 };
1171 let mut predictions = PredictionBlock {
1172 prediction_id: None,
1173 producer_node: NodeId::new("model:pls").unwrap(),
1174 partition: PredictionPartition::Validation,
1175 fold_id: None,
1176 sample_ids: vec![sid("sample:1")],
1177 values: vec![vec![f64::INFINITY]],
1178 target_names: vec!["y".to_string()],
1179 };
1180 assert!(score_regression_prediction_block(
1181 &predictions,
1182 &targets,
1183 &[RegressionMetricKind::Rmse],
1184 )
1185 .is_err());
1186
1187 predictions.values = vec![vec![1.0], vec![1.0]];
1188 predictions.sample_ids = vec![sid("sample:1"), sid("sample:1")];
1189 assert!(score_regression_prediction_block(
1190 &predictions,
1191 &targets,
1192 &[RegressionMetricKind::Rmse],
1193 )
1194 .is_err());
1195 }
1196
1197 #[test]
1198 fn constant_target_r2_is_finite_and_deterministic() {
1199 let targets = RegressionTargetBlock {
1200 level: PredictionLevel::Sample,
1201 unit_ids: vec![sample_unit("sample:1"), sample_unit("sample:2")],
1202 values: vec![vec![2.0], vec![2.0]],
1203 target_names: vec!["y".to_string()],
1204 };
1205 let exact_predictions = PredictionBlock {
1206 prediction_id: None,
1207 producer_node: NodeId::new("model:exact").unwrap(),
1208 partition: PredictionPartition::Validation,
1209 fold_id: None,
1210 sample_ids: vec![sid("sample:1"), sid("sample:2")],
1211 values: vec![vec![2.0], vec![2.0]],
1212 target_names: vec!["y".to_string()],
1213 };
1214 let exact_report = score_regression_prediction_block(
1215 &exact_predictions,
1216 &targets,
1217 &[RegressionMetricKind::R2],
1218 )
1219 .unwrap();
1220 assert_close(exact_report.metrics["r2"], 1.0);
1221
1222 let off_predictions = PredictionBlock {
1223 values: vec![vec![2.0], vec![3.0]],
1224 ..exact_predictions
1225 };
1226 let off_report = score_regression_prediction_block(
1227 &off_predictions,
1228 &targets,
1229 &[RegressionMetricKind::R2],
1230 )
1231 .unwrap();
1232 assert_close(off_report.metrics["r2"], 0.0);
1233 }
1234
1235 fn score_report(
1236 partition: PredictionPartition,
1237 fold: Option<&str>,
1238 rmse: f64,
1239 ) -> RegressionMetricReport {
1240 RegressionMetricReport {
1241 prediction_id: None,
1242 producer_node: NodeId::new("model:compat.0").unwrap(),
1243 variant_id: None,
1244 variant_label: None,
1245 partition,
1246 fold_id: fold.map(|value| FoldId::new(value).unwrap()),
1247 level: PredictionLevel::Sample,
1248 row_count: 10,
1249 target_width: 1,
1250 target_names: vec!["y".to_string()],
1251 metrics: BTreeMap::from([("rmse".to_string(), rmse), ("r2".to_string(), 0.5)]),
1252 }
1253 }
1254
1255 #[test]
1256 fn score_set_round_trips_validates_and_rejects_duplicates() {
1257 let set = ScoreSet {
1258 schema_version: SCORE_SET_SCHEMA_VERSION,
1259 plan_id: "plan:demo".to_string(),
1260 selection_metric: Some("rmse".to_string()),
1261 reports: vec![
1262 score_report(PredictionPartition::Validation, Some("avg"), 18.75),
1263 score_report(PredictionPartition::Test, Some("final"), 13.28),
1264 ],
1265 };
1266 set.validate().unwrap();
1267
1268 let json = serde_json::to_string(&set).unwrap();
1270 let back: ScoreSet = serde_json::from_str(&json).unwrap();
1271 assert_eq!(back, set);
1272
1273 let parsed: ScoreSet =
1275 serde_json::from_value(serde_json::json!({"plan_id": "p", "reports": []})).unwrap();
1276 assert_eq!(parsed.schema_version, SCORE_SET_SCHEMA_VERSION);
1277
1278 let dup = ScoreSet {
1280 reports: vec![
1281 score_report(PredictionPartition::Test, Some("final"), 1.0),
1282 score_report(PredictionPartition::Test, Some("final"), 2.0),
1283 ],
1284 ..set.clone()
1285 };
1286 assert!(dup.validate().is_err());
1287
1288 let blank = ScoreSet {
1290 plan_id: " ".to_string(),
1291 reports: vec![score_report(PredictionPartition::Test, Some("final"), 1.0)],
1292 ..set
1293 };
1294 assert!(blank.validate().is_err());
1295 }
1296
1297 #[test]
1298 fn accuracy_and_balanced_accuracy_match_sklearn_on_imbalanced_classification() {
1299 let predictions = PredictionBlock {
1309 prediction_id: Some("pred:classif".to_string()),
1310 producer_node: NodeId::new("model:rf").unwrap(),
1311 partition: PredictionPartition::Validation,
1312 fold_id: None,
1313 sample_ids: (0..10).map(|i| sid(&format!("s{i}"))).collect(),
1314 values: vec![
1315 vec![0.0],
1316 vec![0.0],
1317 vec![0.0],
1318 vec![0.0],
1319 vec![0.0],
1320 vec![0.0],
1321 vec![1.0],
1322 vec![0.0],
1323 vec![0.0],
1324 vec![0.0],
1325 ],
1326 target_names: vec!["y".to_string()],
1327 };
1328 let targets = RegressionTargetBlock {
1329 level: PredictionLevel::Sample,
1330 unit_ids: (0..10).map(|i| sample_unit(&format!("s{i}"))).collect(),
1331 values: vec![
1332 vec![0.0],
1333 vec![0.0],
1334 vec![0.0],
1335 vec![0.0],
1336 vec![0.0],
1337 vec![0.0],
1338 vec![1.0],
1339 vec![1.0],
1340 vec![2.0],
1341 vec![2.0],
1342 ],
1343 target_names: vec!["y".to_string()],
1344 };
1345
1346 let report = score_regression_prediction_block(
1347 &predictions,
1348 &targets,
1349 &[
1350 RegressionMetricKind::Accuracy,
1351 RegressionMetricKind::BalancedAccuracy,
1352 ],
1353 )
1354 .unwrap();
1355
1356 assert_close(report.metrics["accuracy"], 0.70);
1357 assert_close(report.metrics["balanced_accuracy"], 0.50);
1358 assert_eq!(
1360 RegressionMetricKind::BalancedAccuracy.objective(),
1361 MetricObjective::Maximize
1362 );
1363 }
1364
1365 #[test]
1366 fn cross_fold_balanced_accuracy_pools_oof_and_matches_sklearn() {
1367 let model = NodeId::new("model:rf").unwrap();
1375 let fold_block = |fold: &str, ids: &[usize], preds: &[f64]| PredictionBlock {
1376 prediction_id: Some(format!("pred:{fold}")),
1377 producer_node: model.clone(),
1378 partition: PredictionPartition::Validation,
1379 fold_id: Some(FoldId::new(fold).unwrap()),
1380 sample_ids: ids.iter().map(|i| sid(&format!("s{i}"))).collect(),
1381 values: preds.iter().map(|p| vec![*p]).collect(),
1382 target_names: vec!["y".to_string()],
1383 };
1384 let target_record = |fold: &str, ids: &[usize], trues: &[f64]| RegressionTargetRecord {
1385 producer_node: model.clone(),
1386 variant_id: None,
1387 partition: PredictionPartition::Validation,
1388 fold_id: Some(FoldId::new(fold).unwrap()),
1389 block: RegressionTargetBlock {
1390 level: PredictionLevel::Sample,
1391 unit_ids: ids.iter().map(|i| sample_unit(&format!("s{i}"))).collect(),
1392 values: trues.iter().map(|t| vec![*t]).collect(),
1393 target_names: vec!["y".to_string()],
1394 },
1395 };
1396
1397 let f0 = (0..5).collect::<Vec<_>>();
1403 let f1 = (5..10).collect::<Vec<_>>();
1404 let blocks = vec![
1405 fold_block("0", &f0, &[0.0, 0.0, 0.0, 1.0, 0.0]),
1406 fold_block("1", &f1, &[0.0, 0.0, 0.0, 0.0, 0.0]),
1407 ];
1408 let targets = vec![
1409 target_record("0", &f0, &[0.0, 0.0, 0.0, 1.0, 2.0]),
1410 target_record("1", &f1, &[0.0, 0.0, 0.0, 1.0, 2.0]),
1411 ];
1412
1413 let outcome = cross_fold_validation_reports(
1414 &blocks,
1415 &targets,
1416 &[
1417 RegressionMetricKind::Accuracy,
1418 RegressionMetricKind::BalancedAccuracy,
1419 ],
1420 FoldPartitionMode::Partition,
1421 )
1422 .unwrap();
1423
1424 assert_eq!(
1425 outcome.reports.len(),
1426 1,
1427 "one pooled `avg` report for the producer"
1428 );
1429 let avg = &outcome.reports[0];
1430 assert_eq!(avg.fold_id, Some(FoldId::new("avg").unwrap()));
1431 assert_eq!(avg.row_count, 10, "all OOF samples pooled exactly once");
1432 assert_close(avg.metrics["accuracy"], 0.70);
1433 assert_close(avg.metrics["balanced_accuracy"], 0.50);
1434
1435 assert_eq!(outcome.oof_averages.len(), 1, "one OOF average block");
1439 let oof = &outcome.oof_averages[0];
1440 assert_eq!(oof.predictions.partition, PredictionPartition::Validation);
1441 assert_eq!(oof.predictions.fold_id, Some(FoldId::new("avg").unwrap()));
1442 assert_eq!(oof.predictions.level, PredictionLevel::Sample);
1443 assert_eq!(oof.predictions.unit_ids.len(), 10);
1444 assert_eq!(oof.y_true.unit_ids, oof.predictions.unit_ids);
1445 assert_eq!(
1448 oof.predictions.values,
1449 vec![
1450 vec![0.0],
1451 vec![0.0],
1452 vec![0.0],
1453 vec![1.0],
1454 vec![0.0],
1455 vec![0.0],
1456 vec![0.0],
1457 vec![0.0],
1458 vec![0.0],
1459 vec![0.0],
1460 ]
1461 );
1462 assert_eq!(
1463 oof.y_true.values,
1464 vec![
1465 vec![0.0],
1466 vec![0.0],
1467 vec![0.0],
1468 vec![1.0],
1469 vec![2.0],
1470 vec![0.0],
1471 vec![0.0],
1472 vec![0.0],
1473 vec![1.0],
1474 vec![2.0],
1475 ]
1476 );
1477 }
1478}