Skip to main content

dag_ml_core/
metrics.rs

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    /// Classification accuracy: fraction of predictions whose label matches the target (integer
23    /// label encoding, matched within 0.5). Meaningless on continuous regression targets (≈0) but
24    /// always emitted so the host can score classification natively without a separate code path.
25    Accuracy,
26    /// Balanced classification accuracy: the macro-average of per-class recall (mean over the
27    /// classes *present in `y_true`* of `correct_in_class / count_in_class`), matching scikit-learn's
28    /// `balanced_accuracy_score`. This is nirs4all's DEFAULT classification ranking metric (its
29    /// `_resolve_effective_metric` returns `balanced_accuracy` for a classification candidate), so it
30    /// must be emitted natively for the dag-ml engine to reproduce the legacy classification
31    /// `cv_best_score`. On a class-collapsed predictor it can be far below plain `accuracy`; on a
32    /// continuous regression target it is meaningless (≈ chance) but always emitted, like `accuracy`.
33    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
117/// Mandatory, central *merge target-coverage* invariant — the single gate every merge reassembly
118/// handler (separation/concat, fusion, off-fold) must pass through before emitting (or declining to
119/// emit) a producer-level [`RegressionTargetBlock`] for a reassembled merge prediction. It closes
120/// audit R-P1-9: a merge that *should* be scored must never silently produce **no** score.
121///
122/// A merge reassembles its output's `y_true` by collecting the per-branch validation/off-fold target
123/// records into `by_sample_target`. The scoring path ([`super::runtime`]'s `apply_result_scoring`)
124/// pairs a prediction block 1:1 with a target block that covers *exactly* its samples — so a partial
125/// target block cannot be scored. There are exactly three legitimate outcomes:
126///
127/// 1. **No contributing branch emitted targets** (`by_sample_target` empty) — the merge is simply
128///    unscored. Returns `Ok(None)`; the caller emits no [`RegressionTargetBlock`]. This is the common
129///    "host never emitted `regression_targets`" case and stays a no-op (unchanged behavior).
130/// 2. **Every merged sample is covered** — emit the 1:1 target block. Returns `Ok(Some(block))` in the
131///    merge's declared `sample_id` order, ready to score.
132/// 3. **At least one branch emitted targets but coverage is INCOMPLETE** — previously the partial
133///    targets were silently dropped (`Vec::new()` → no score), so a merge that should have been scored
134///    silently vanished from selection. This is now a hard validation **ERROR**: once ANY branch
135///    contributes targets, the merge universe must be covered completely or the run fails loudly.
136///
137/// `merge_sample_ids` is the merge output's sample order (the universe to cover); `target_names` is the
138/// reassembled target name vector (already unified across branches by the caller). The map is consumed
139/// by `remove` on the success path so the caller need not clone it.
140pub 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    /// Variant this score belongs to — distinguishes per-variant candidates when a generated
188    /// campaign scores several variants, so native SELECT can pick the best. Skipped (None) for
189    /// single-variant runs, so existing fixtures/fingerprints are byte-identical.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub variant_id: Option<VariantId>,
192    /// Cross-language CONTENT fingerprint (hex sha256) of the operator-variant this score belongs to:
193    /// the canonical form of the variant's LOWERED operator sub-sequence (Phase 5). The nirs4all host
194    /// recomputes the SAME bytes from its own operator-choice config, so it can map a per-variant
195    /// dag-ml report back to the config that produced it (replacing a brittle positional zip). Set
196    /// only for operator-SELECT reports (the choice fingerprint from `OperatorVariantModel`'s
197    /// `variant_labels`); skipped (None) for param-variant / single-variant runs, so existing
198    /// fixtures/fingerprints stay byte-identical.
199    #[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
359/// Current on-disk schema version for [`ScoreSet`].
360pub const SCORE_SET_SCHEMA_VERSION: u32 = 1;
361
362fn default_score_set_schema_version() -> u32 {
363    SCORE_SET_SCHEMA_VERSION
364}
365
366/// A persisted collection of per-block regression metric reports — the native, cross-language
367/// score record produced by a run (one report per `(producer_node, partition, fold_id, level)`).
368///
369/// Unlike the prediction-*value* cache (which is Validation-only and leakage-gated), scores are
370/// scalars derived from `y_true` and carry no feature data, so they are safe to persist for
371/// every partition (train / validation / test / final). This is the score half of "dag-ml owns
372/// prediction/score persistence natively" — the Python (or any host) `RunResult` reads these
373/// scalars by identity, with no recomputation.
374/// Identity of a score report within a [`ScoreSet`] — unique per variant, partition, fold and level.
375type 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    /// The metric SELECT optimized for this run (e.g. `"rmse"`), if a selection ran. Metadata only.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub selection_metric: Option<String>,
391    pub reports: Vec<RegressionMetricReport>,
392}
393
394impl ScoreSet {
395    /// Validate the version, plan id, every report, and report-key uniqueness.
396    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
607/// Balanced classification accuracy for one target column: the macro-average of per-class recall over
608/// the integer labels present in `y_true`, matching scikit-learn's `balanced_accuracy_score`. Labels
609/// are matched the same way as [`RegressionMetricKind::Accuracy`] — a prediction counts for true class
610/// `c` when `|pred - c| < 0.5` — so the two metrics share one label-encoding convention. Returns the
611/// unweighted mean of `correct_in_class / count_in_class`; an empty target set yields `0.0` (the rows
612/// are non-empty here because the scoring path rejects zero-row blocks before this is reached).
613fn balanced_accuracy_for_target(
614    target_idx: usize,
615    predictions: &[&[f64]],
616    targets: &[&[f64]],
617) -> f64 {
618    // Group sample rows by their (rounded) true class label, preserving determinism via BTreeMap.
619    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/// A host-emitted `y_true` block tagged with the prediction it scores (producer/partition/fold), so
689/// the runtime can aggregate ground truth across folds to score cross-fold ensembles natively.
690#[derive(Clone, Debug, PartialEq)]
691pub struct RegressionTargetRecord {
692    pub producer_node: NodeId,
693    /// Variant that produced the scored block — lets the cross-fold OOF average be computed
694    /// per-variant (for native SELECT) without tagging every PredictionBlock with a variant.
695    pub variant_id: Option<VariantId>,
696    pub partition: PredictionPartition,
697    pub fold_id: Option<FoldId>,
698    pub block: RegressionTargetBlock,
699}
700
701/// Combine a producer's per-fold VALIDATION `y_true` into one block (dedup by unit id — a sample's
702/// ground truth is fold-independent), aligned to the producer's OOF samples.
703///
704/// Defense-in-depth (audit R-P0-1): records are grouped only by `producer_node`, but each carries a
705/// `variant_id`. A sample's ground truth is variant-independent, so the same unit seen again must
706/// carry the SAME `y_true`. If two records (e.g. from two variants sharing one context) disagree on a
707/// unit's target, the ground truth has been mixed and the combined block would silently score against
708/// a corrupted reference — that is refused rather than keeping whichever value happened to be first.
709fn 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/// The per-sample cross-fold OOF average of one producer, surfaced alongside its scalar report so the
750/// host can show each OOF sample's averaged prediction (nirs4all's `(validation, avg)` row y_pred),
751/// not only the pooled scalar. The block is keyed by `producer_node` / `partition = Validation` /
752/// `fold_id = "avg"` — identical to the scalar [`RegressionMetricReport`] this pairs with — and the
753/// `y_true` covers exactly the block's samples (same id set), so the host pairs them by id. This is
754/// REPORT-grade output: it carries no variant tag (the block has none; the variant is stamped on the
755/// report downstream) and never feeds a training/feature path, so OOF/leakage invariants are
756/// unaffected — it is purely the same averaged values the scalar was computed from, exposed per sample.
757#[derive(Clone, Debug, PartialEq)]
758pub struct OofAverageBlock {
759    pub predictions: AggregatedPredictionBlock,
760    pub y_true: RegressionTargetBlock,
761}
762
763/// The output of [`cross_fold_validation_reports`]: the scalar cross-fold OOF average reports (one per
764/// producer, `fold_id = "avg"`) plus — purely additively — the per-sample OOF average block + `y_true`
765/// each report was computed from. `reports` is byte-identical to the historical `Vec` return; callers
766/// that only need the scalars read `reports` and ignore `oof_averages`.
767#[derive(Clone, Debug, Default, PartialEq)]
768pub struct CrossFoldValidation {
769    pub reports: Vec<RegressionMetricReport>,
770    pub oof_averages: Vec<OofAverageBlock>,
771}
772
773/// Score the cross-fold OOF average per producer: concatenate each producer's per-fold VALIDATION
774/// predictions into one block and score it against the combined `y_true`. Yields one report per
775/// producer with `fold_id = "avg"` — nirs4all's `cv_best_score` row — plus, additively, the per-sample
776/// OOF average block + `y_true` each report was computed from (so the host can fill the `(validation,
777/// avg)` row's per-sample y_pred, not only the scalar). The per-fold join is identity-keyed; producers
778/// with a single fold are skipped (nothing to ensemble).
779///
780/// `partition_mode` mirrors the campaign [`FoldPartitionMode`]:
781/// under `Partition` (KFold) the per-producer OOF must be unique (each sample scored exactly once);
782/// under `Resampled` (ShuffleSplit / repeated CV) a sample may appear in several folds — those
783/// predictions are averaged by [`reduce_predictions_across_folds`] — so the across-fold uniqueness
784/// gate is relaxed accordingly.
785pub 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        // Mandatory OOF coverage gate (spec rule 3), mode-aware. Under `Partition` the producer's
813        // per-fold validation blocks must be UNIQUE — exactly one validation prediction per sample; a
814        // sample appearing in two blocks would be a duplicated fold or — since `PredictionBlock` carries
815        // no variant tag — two variants' OOF in a shared context (audit R-P0-1), and is refused (the
816        // scoring-path analogue of the runtime merge handler's "mixes several variants" guard, so
817        // cross-variant CV scores can NEVER mix here). Under `Resampled` (ShuffleSplit / repeated CV) a
818        // sample is legitimately validated in several folds and its predictions are averaged by
819        // `reduce_predictions_across_folds` below, so across-fold multiplicity is allowed; the per-block
820        // within-fold uniqueness still holds via `validate_content`.
821        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            // No y_true was emitted for this producer (e.g. mock controllers) — nothing to score.
826            continue;
827        }
828        let average = reduce_predictions_across_folds(blocks, None, "avg")?;
829        // The scalar report is computed from `average` EXACTLY as before — byte-identical. The
830        // additive per-sample surface below reuses the SAME `average` values and the SAME `targets`,
831        // so it cannot perturb any score or `row_count`.
832        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
843/// Build the per-sample OOF average surface (block + `y_true`) from the SAME `average`
844/// [`PredictionBlock`] and combined `targets` the scalar report was computed from. The block is the
845/// sample-level lift of `average` (its sample ids become `Sample` unit ids, values unchanged), keyed
846/// identically (producer / `Validation` / `avg`). The `y_true` is `targets` realigned to the block's
847/// sample order so a host pairs y_pred ↔ y_true per sample without re-sorting; every `average` sample
848/// has a y_true row because [`combine_validation_targets`] pools every per-fold validation record and
849/// the OOF coverage gate guarantees each averaged sample was validated.
850fn 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        // No contributing branch emitted targets: the merge is legitimately unscored.
936        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        // Emitted in the merge's declared sample order, not map order.
966        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        // R-P1-9: one branch contributed a target (s1) but the merge universe also
979        // covers s2, which has no y_true row. Previously this silently dropped the
980        // targets (no score); it must now be a hard validation error.
981        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        // JSON round-trip is lossless.
1269        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        // schema_version defaults when omitted (forward-compatible read).
1274        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        // Duplicate (producer_node, partition, fold_id, level) is rejected.
1279        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        // Empty plan_id is rejected.
1289        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        // #60 root-cause lock: dag-ml emits BOTH plain `accuracy` and `balanced_accuracy`. nirs4all's
1300        // DEFAULT classification ranking metric is balanced_accuracy (its `_resolve_effective_metric`),
1301        // so the legacy `cv_best_score` for a classification sweep is balanced_accuracy — NOT plain
1302        // accuracy. A class-collapsed predictor on imbalanced data makes the two diverge sharply (the
1303        // 0.32-vs-0.16 STOP report). Ground truth here is scikit-learn on the same labels:
1304        //   y    = [0,0,0,0,0,0, 1,1, 2,2]  (majority class 0)
1305        //   pred = [0,0,0,0,0,0, 1,0, 0,0]  (collapses wrong rows to class 0)
1306        //   accuracy_score          = 7/10 = 0.70
1307        //   balanced_accuracy_score = mean(recall(c0)=6/6, recall(c1)=1/2, recall(c2)=0/2) = 0.50
1308        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        // Both maximize — the host SELECT ranks them in the same direction.
1359        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        // The real #60 path: per-fold VALIDATION OOF blocks pooled into the `avg` report (nirs4all's
1368        // `cv_best_score` row), scored against the combined y_true. Two disjoint KFold folds carry the
1369        // same imbalanced class-collapse as the per-block lock above, so the POOLED OOF accuracy is
1370        // 0.70 and pooled balanced_accuracy is 0.50 — proving the cross-fold reduction + metric agree
1371        // with scikit-learn's `accuracy_score` / `balanced_accuracy_score` on the same labels, and that
1372        // dag-ml's `accuracy` (0.70) was never wrong: it is simply a DIFFERENT metric from the legacy's
1373        // default `balanced_accuracy` (0.50).
1374        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        // Fold 0 (samples 0..5): preds [0,0,0,1,0] vs true [0,0,0,1,2]
1398        // Fold 1 (samples 5..10): preds [0,0,0,0,0] vs true [0,0,0,1,2]
1399        // Pooled: true=[0,0,0,1,2,0,0,0,1,2], pred=[0,0,0,1,0,0,0,0,0,0]
1400        //   accuracy          = 7/10 = 0.70
1401        //   balanced_accuracy = mean(recall0=6/6, recall1=1/2, recall2=0/2) = 0.50
1402        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        // Additive per-sample OOF average surface: one block per scored producer, keyed identically to
1436        // the scalar report (producer / Validation / avg), with the SAME pooled values and one y_true
1437        // row per averaged sample (same id set), realigned to the block's sample order.
1438        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        // The pooled per-sample preds are the across-fold mean (each KFold sample validated once):
1446        // [0,0,0,1,0] ++ [0,0,0,0,0] against y_true [0,0,0,1,2] ++ [0,0,0,1,2].
1447        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}