Skip to main content

dag_ml_core/
chain_effect.rs

1//! Chain-effect analysis contract + per-dataset score normalization.
2//!
3//! Turns a corpus of executed *linear* pipelines ("chains", e.g.
4//! `SNV → SavGol → PLS`), each with a comparable scalar score, into a stable,
5//! serializable [`ChainEffectAnalysis`] artifact: every chain projected to a
6//! comparable "goodness" (higher is always better) under a normalization
7//! [`ChainEffectLens`]. The *authoritative* piece that must live natively here
8//! is the per-dataset score normalization (rank / z); downstream consumers such
9//! as `nirs4all-ui/chains` derive the per-node / position / order aggregates
10//! descriptively from the emitted points.
11//!
12//! Boundary: this operates only on stable identifiers, ordered step
13//! tokens/roles and scalar scores — never on feature matrices, tensors or
14//! fitted operators, consistent with the dag-ml ownership boundary.
15//!
16//! Construction is fail-closed: [`ChainEffectAnalysis::from_observations`]
17//! rejects empty input, non-finite scores, duplicate ids, mixed evaluation
18//! scopes, and (for the rank/z lenses) any observation missing a dataset
19//! identity. Repeated ordered tokens are preserved verbatim — position and
20//! order are meaningful to downstream consumers.
21//!
22//! Deferred (a follow-up slice): building [`ChainObservation`]s from a
23//! [`crate::plan::GraphPlan`] + [`crate::metrics::ScoreSet`]. `ScoreSet` carries
24//! no dataset/source identity (only `plan_id`); the dataset key must come from
25//! the representation mapping
26//! ([`crate::data::RepresentationSampleObservationMapping`]) and the per-variant
27//! node walk needs its own design, so this slice takes host-supplied
28//! observations.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use serde::{Deserialize, Serialize};
33
34use crate::error::{DagMlError, Result};
35use crate::graph::NodeKind;
36use crate::selection::{EvaluationScope, MetricObjective};
37
38/// Stable `$id` of the serialized chain-effect analysis schema.
39pub const CHAIN_EFFECT_SCHEMA_ID: &str =
40    "https://github.com/GBeurier/dag-ml/schemas/chain_effect_analysis.v1.schema.json";
41
42/// Current schema version of [`ChainEffectAnalysis`].
43pub const CHAIN_EFFECT_SCHEMA_VERSION: u32 = 1;
44
45/// Coarse role of a chain step; drives color/legend and position/order scoping
46/// in downstream consumers.
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ChainStepRole {
50    Split,
51    Preprocess,
52    Feature,
53    Model,
54    Augmentation,
55    Target,
56    Other,
57}
58
59/// Map a graph [`NodeKind`] to its coarse [`ChainStepRole`].
60///
61/// Exhaustive by design (no wildcard arm): adding a `NodeKind` must be
62/// classified here rather than silently falling through to `Other`.
63pub fn chain_role_for_node_kind(kind: &NodeKind) -> ChainStepRole {
64    match kind {
65        NodeKind::Transform => ChainStepRole::Preprocess,
66        NodeKind::YTransform => ChainStepRole::Target,
67        NodeKind::Split => ChainStepRole::Split,
68        NodeKind::Model => ChainStepRole::Model,
69        NodeKind::Augmentation => ChainStepRole::Augmentation,
70        NodeKind::FeatureJoin | NodeKind::SourceJoin => ChainStepRole::Feature,
71        // `Exclude` removes samples from training (a filter/control op, not a
72        // feature-space transform), so it is classified as `Other`.
73        NodeKind::Fork
74        | NodeKind::Map
75        | NodeKind::PredictionJoin
76        | NodeKind::MixedJoin
77        | NodeKind::Tag
78        | NodeKind::Exclude
79        | NodeKind::Adapter
80        | NodeKind::Aggregator
81        | NodeKind::Generator
82        | NodeKind::Restructure
83        | NodeKind::Tuner
84        | NodeKind::Subgraph
85        | NodeKind::Chart => ChainStepRole::Other,
86    }
87}
88
89/// Normalization lens that makes heterogeneous datasets comparable.
90#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum ChainEffectLens {
93    /// Oriented score as-is (best for a single dataset).
94    Raw,
95    /// Percentile rank within each dataset (0..1, 1 = best).
96    RankByDataset,
97    /// Z-score within each dataset (higher = better).
98    ZByDataset,
99}
100
101impl ChainEffectLens {
102    /// Wire spelling of the lens (matches the `serde` rename).
103    pub fn as_wire(self) -> &'static str {
104        match self {
105            Self::Raw => "raw",
106            Self::RankByDataset => "rank_by_dataset",
107            Self::ZByDataset => "z_by_dataset",
108        }
109    }
110
111    /// Whether this lens requires an explicit dataset identity per observation.
112    pub fn requires_dataset(self) -> bool {
113        matches!(self, Self::RankByDataset | Self::ZByDataset)
114    }
115}
116
117/// The metric the scores are expressed in, plus its optimization direction.
118#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ChainEffectMetric {
121    pub key: String,
122    pub label: String,
123    /// `true` for error metrics (nRMSE, RMSE); `false` for R²/accuracy.
124    pub lower_is_better: bool,
125}
126
127impl ChainEffectMetric {
128    /// The equivalent [`MetricObjective`] for the metric direction.
129    pub fn objective(&self) -> MetricObjective {
130        if self.lower_is_better {
131            MetricObjective::Minimize
132        } else {
133            MetricObjective::Maximize
134        }
135    }
136
137    fn validate(&self) -> Result<()> {
138        require_non_empty("chain effect metric key", &self.key)?;
139        require_non_empty("chain effect metric label", &self.label)
140    }
141}
142
143/// One node occurrence inside a chain.
144#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct ChainEffectStep {
147    pub token: String,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub label: Option<String>,
150    pub role: ChainStepRole,
151}
152
153impl ChainEffectStep {
154    fn validate(&self, ctx: &str) -> Result<()> {
155        require_non_empty(&format!("{ctx} token"), &self.token)?;
156        if let Some(label) = &self.label {
157            require_non_empty(&format!("{ctx} label"), label)?;
158        }
159        Ok(())
160    }
161}
162
163/// Host-supplied observation: one executed chain with a comparable score.
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct ChainObservation {
167    pub id: String,
168    /// Ordered steps, first → last (repeats preserved).
169    pub steps: Vec<ChainEffectStep>,
170    pub score: f64,
171    /// Dataset the chain ran on — the unit of per-dataset normalization.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub dataset: Option<String>,
174    /// Source / modality (multisource, multimodal).
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub source: Option<String>,
177    /// Evaluation scope provenance of `score`; mixing distinct scopes is refused.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub evaluation_scope: Option<EvaluationScope>,
180}
181
182/// One chain projected to a comparable goodness (higher = better).
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct ChainEffectPoint {
186    pub id: String,
187    /// Raw metric value (for tooltips).
188    pub score: f64,
189    /// Oriented, lens-normalized score; higher is always better.
190    pub goodness: f64,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub dataset: Option<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub source: Option<String>,
195    /// Ordered steps, verbatim from the observation (repeats preserved).
196    pub ordered_tokens: Vec<ChainEffectStep>,
197}
198
199/// The serialized chain-effect analysis artifact.
200#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ChainEffectAnalysis {
203    pub schema_id: String,
204    pub schema_version: u32,
205    pub metric: ChainEffectMetric,
206    pub lens: ChainEffectLens,
207    /// Reference goodness (global median); the diverging color pivot downstream.
208    pub baseline: f64,
209    /// Shared evaluation scope of the corpus, when every observation declared one.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub evaluation_scope: Option<EvaluationScope>,
212    pub points: Vec<ChainEffectPoint>,
213}
214
215impl ChainEffectAnalysis {
216    /// Build the authoritative analysis from host-supplied observations.
217    ///
218    /// Fail-closed: rejects empty input, non-finite scores, duplicate ids,
219    /// empty step lists, mixed evaluation scopes, and (for rank/z lenses) any
220    /// observation without a dataset identity.
221    pub fn from_observations(
222        observations: &[ChainObservation],
223        metric: ChainEffectMetric,
224        lens: ChainEffectLens,
225    ) -> Result<Self> {
226        metric.validate()?;
227        if observations.is_empty() {
228            return Err(DagMlError::RuntimeValidation(
229                "chain effect analysis requires at least one observation".to_string(),
230            ));
231        }
232
233        let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
234        let mut scopes: BTreeSet<Option<EvaluationScope>> = BTreeSet::new();
235        for obs in observations {
236            require_non_empty("chain observation id", &obs.id)?;
237            if !seen_ids.insert(obs.id.as_str()) {
238                return Err(DagMlError::RuntimeValidation(format!(
239                    "duplicate chain observation id `{}`",
240                    obs.id
241                )));
242            }
243            if !obs.score.is_finite() {
244                return Err(DagMlError::RuntimeValidation(format!(
245                    "chain observation `{}` has a non-finite score",
246                    obs.id
247                )));
248            }
249            if obs.steps.is_empty() {
250                return Err(DagMlError::RuntimeValidation(format!(
251                    "chain observation `{}` has no steps",
252                    obs.id
253                )));
254            }
255            for step in &obs.steps {
256                step.validate(&format!("chain observation `{}` step", obs.id))?;
257            }
258            if let Some(dataset) = &obs.dataset {
259                require_non_empty(&format!("chain observation `{}` dataset", obs.id), dataset)?;
260            }
261            if let Some(source) = &obs.source {
262                require_non_empty(&format!("chain observation `{}` source", obs.id), source)?;
263            }
264            scopes.insert(obs.evaluation_scope);
265        }
266        if scopes.len() > 1 {
267            return Err(DagMlError::RuntimeValidation(
268                "chain observations mix different evaluation scopes".to_string(),
269            ));
270        }
271        let evaluation_scope = scopes.into_iter().next().flatten();
272
273        if lens.requires_dataset() && observations.iter().any(|obs| obs.dataset.is_none()) {
274            return Err(DagMlError::RuntimeValidation(format!(
275                "lens `{}` requires a dataset id on every observation",
276                lens.as_wire()
277            )));
278        }
279
280        let lower = metric.lower_is_better;
281        let oriented: Vec<f64> = observations
282            .iter()
283            .map(|obs| orient(obs.score, lower))
284            .collect();
285
286        let mut goodness = vec![0.0_f64; observations.len()];
287        match lens {
288            ChainEffectLens::Raw => {
289                goodness.copy_from_slice(&oriented);
290            }
291            ChainEffectLens::RankByDataset | ChainEffectLens::ZByDataset => {
292                let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
293                for (index, obs) in observations.iter().enumerate() {
294                    // `requires_dataset` guaranteed a dataset above.
295                    let key = obs.dataset.as_deref().unwrap_or_default();
296                    groups.entry(key).or_default().push(index);
297                }
298                for indices in groups.values() {
299                    let values: Vec<f64> = indices.iter().map(|&index| oriented[index]).collect();
300                    let transformed = if matches!(lens, ChainEffectLens::RankByDataset) {
301                        percentile_ranks(&values)
302                    } else {
303                        z_scores(&values)
304                    };
305                    for (position, &index) in indices.iter().enumerate() {
306                        goodness[index] = transformed[position];
307                    }
308                }
309            }
310        }
311
312        for (index, value) in goodness.iter().enumerate() {
313            if !value.is_finite() {
314                return Err(DagMlError::RuntimeValidation(format!(
315                    "chain observation `{}` produced a non-finite goodness",
316                    observations[index].id
317                )));
318            }
319        }
320
321        let baseline = median(&goodness);
322        if !baseline.is_finite() {
323            return Err(DagMlError::RuntimeValidation(
324                "chain effect baseline is non-finite".to_string(),
325            ));
326        }
327
328        let points = observations
329            .iter()
330            .enumerate()
331            .map(|(index, obs)| ChainEffectPoint {
332                id: obs.id.clone(),
333                score: obs.score,
334                goodness: goodness[index],
335                dataset: obs.dataset.clone(),
336                source: obs.source.clone(),
337                ordered_tokens: obs.steps.clone(),
338            })
339            .collect();
340
341        let analysis = Self {
342            schema_id: CHAIN_EFFECT_SCHEMA_ID.to_string(),
343            schema_version: CHAIN_EFFECT_SCHEMA_VERSION,
344            metric,
345            lens,
346            baseline,
347            evaluation_scope,
348            points,
349        };
350        analysis.validate()?;
351        Ok(analysis)
352    }
353
354    /// Parse + validate a serialized artifact.
355    pub fn from_json(json: &str) -> Result<Self> {
356        let value: Self = serde_json::from_str(json)?;
357        value.validate()?;
358        Ok(value)
359    }
360
361    /// Serialize to canonical JSON.
362    pub fn to_json(&self) -> Result<String> {
363        Ok(serde_json::to_string(self)?)
364    }
365
366    /// Structural + semantic validation of the artifact.
367    pub fn validate(&self) -> Result<()> {
368        if self.schema_id != CHAIN_EFFECT_SCHEMA_ID {
369            return Err(DagMlError::RuntimeValidation(format!(
370                "chain effect analysis schema_id `{}` is unexpected (current `{CHAIN_EFFECT_SCHEMA_ID}`)",
371                self.schema_id
372            )));
373        }
374        if self.schema_version != CHAIN_EFFECT_SCHEMA_VERSION {
375            return Err(DagMlError::RuntimeValidation(format!(
376                "chain effect analysis schema_version {} is unsupported (current {CHAIN_EFFECT_SCHEMA_VERSION})",
377                self.schema_version
378            )));
379        }
380        self.metric.validate()?;
381        if self.points.is_empty() {
382            return Err(DagMlError::RuntimeValidation(
383                "chain effect analysis has no points".to_string(),
384            ));
385        }
386        if !self.baseline.is_finite() {
387            return Err(DagMlError::RuntimeValidation(
388                "chain effect baseline is non-finite".to_string(),
389            ));
390        }
391
392        let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
393        for point in &self.points {
394            require_non_empty("chain effect point id", &point.id)?;
395            if !seen_ids.insert(point.id.as_str()) {
396                return Err(DagMlError::RuntimeValidation(format!(
397                    "duplicate chain effect point id `{}`",
398                    point.id
399                )));
400            }
401            if !point.score.is_finite() || !point.goodness.is_finite() {
402                return Err(DagMlError::RuntimeValidation(format!(
403                    "chain effect point `{}` has a non-finite score or goodness",
404                    point.id
405                )));
406            }
407            if point.ordered_tokens.is_empty() {
408                return Err(DagMlError::RuntimeValidation(format!(
409                    "chain effect point `{}` has no ordered tokens",
410                    point.id
411                )));
412            }
413            for step in &point.ordered_tokens {
414                step.validate(&format!("chain effect point `{}` token", point.id))?;
415            }
416            if let Some(dataset) = &point.dataset {
417                require_non_empty(
418                    &format!("chain effect point `{}` dataset", point.id),
419                    dataset,
420                )?;
421            }
422            if let Some(source) = &point.source {
423                require_non_empty(&format!("chain effect point `{}` source", point.id), source)?;
424            }
425        }
426
427        if self.lens.requires_dataset() && self.points.iter().any(|point| point.dataset.is_none()) {
428            return Err(DagMlError::RuntimeValidation(format!(
429                "lens `{}` requires a dataset id on every point",
430                self.lens.as_wire()
431            )));
432        }
433
434        // Semantic invariants of the goodness/baseline definitions, so a parsed
435        // artifact cannot claim a lens whose numbers contradict it.
436        let goodness: Vec<f64> = self.points.iter().map(|point| point.goodness).collect();
437        let recomputed = median(&goodness);
438        if !approx_eq(recomputed, self.baseline) {
439            return Err(DagMlError::RuntimeValidation(format!(
440                "chain effect baseline {} is not the median of goodness ({recomputed})",
441                self.baseline
442            )));
443        }
444        match self.lens {
445            ChainEffectLens::Raw => {
446                let lower = self.metric.lower_is_better;
447                for point in &self.points {
448                    if !approx_eq(point.goodness, orient(point.score, lower)) {
449                        return Err(DagMlError::RuntimeValidation(format!(
450                            "chain effect point `{}` raw goodness disagrees with its oriented score",
451                            point.id
452                        )));
453                    }
454                }
455            }
456            ChainEffectLens::RankByDataset => {
457                for point in &self.points {
458                    if point.goodness < -RANK_EPS || point.goodness > 1.0 + RANK_EPS {
459                        return Err(DagMlError::RuntimeValidation(format!(
460                            "chain effect point `{}` rank goodness {} is outside [0, 1]",
461                            point.id, point.goodness
462                        )));
463                    }
464                }
465            }
466            ChainEffectLens::ZByDataset => {}
467        }
468        Ok(())
469    }
470}
471
472/// Absolute tolerance for rank-goodness bounds.
473const RANK_EPS: f64 = 1e-9;
474
475/// Relative/absolute float comparison for validation invariants.
476fn approx_eq(a: f64, b: f64) -> bool {
477    (a - b).abs() <= 1e-9 * (1.0 + a.abs().max(b.abs()))
478}
479
480fn require_non_empty(label: &str, value: &str) -> Result<()> {
481    if value.trim().is_empty() {
482        return Err(DagMlError::RuntimeValidation(format!("{label} is empty")));
483    }
484    Ok(())
485}
486
487/// Orient a score so higher is always better.
488pub fn orient(score: f64, lower_is_better: bool) -> f64 {
489    if lower_is_better {
490        -score
491    } else {
492        score
493    }
494}
495
496/// Percentile rank of each value in `[0, 1]` (1 = largest); average ties.
497///
498/// A single value maps to `0.5`; an empty slice returns an empty vector.
499pub fn percentile_ranks(values: &[f64]) -> Vec<f64> {
500    let n = values.len();
501    if n == 0 {
502        return Vec::new();
503    }
504    if n == 1 {
505        return vec![0.5];
506    }
507    let mut order: Vec<usize> = (0..n).collect();
508    order.sort_by(|&a, &b| values[a].total_cmp(&values[b]));
509    let denom = (n - 1) as f64;
510    let mut ranks = vec![0.0_f64; n];
511    let mut i = 0;
512    while i < n {
513        let mut j = i;
514        // Group ties by numeric equality so `-0.0` and `+0.0` average together
515        // (a deterministic `total_cmp` sort orders them; only tie *grouping*
516        // should treat them as equal).
517        while j + 1 < n && values[order[j + 1]] == values[order[i]] {
518            j += 1;
519        }
520        let averaged = ((i + j) as f64) / 2.0 / denom;
521        for slot in &order[i..=j] {
522            ranks[*slot] = averaged;
523        }
524        i = j + 1;
525    }
526    ranks
527}
528
529/// Sample z-scores (mean 0, unit sd, `n - 1` divisor); zero variance → all `0`.
530pub fn z_scores(values: &[f64]) -> Vec<f64> {
531    let n = values.len();
532    if n == 0 {
533        return Vec::new();
534    }
535    if n < 2 {
536        return vec![0.0; n];
537    }
538    let mean = values.iter().sum::<f64>() / n as f64;
539    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n as f64 - 1.0);
540    let sd = variance.sqrt();
541    if sd == 0.0 || !sd.is_finite() {
542        return vec![0.0; n];
543    }
544    values.iter().map(|v| (v - mean) / sd).collect()
545}
546
547/// Median of the finite values (linear interpolation at the midpoint).
548pub fn median(values: &[f64]) -> f64 {
549    let mut sorted: Vec<f64> = values.iter().copied().filter(|v| v.is_finite()).collect();
550    if sorted.is_empty() {
551        return f64::NAN;
552    }
553    sorted.sort_by(f64::total_cmp);
554    let n = sorted.len();
555    if n % 2 == 1 {
556        sorted[n / 2]
557    } else {
558        // Overflow-safe midpoint (avoids `MAX + MAX`).
559        let lo = sorted[n / 2 - 1];
560        let hi = sorted[n / 2];
561        lo + (hi - lo) / 2.0
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    fn step(token: &str, role: ChainStepRole) -> ChainEffectStep {
570        ChainEffectStep {
571            token: token.to_string(),
572            label: Some(token.to_uppercase()),
573            role,
574        }
575    }
576
577    fn obs(id: &str, dataset: &str, pre: &str, score: f64) -> ChainObservation {
578        ChainObservation {
579            id: id.to_string(),
580            steps: vec![
581                step("split_kfold", ChainStepRole::Split),
582                step(pre, ChainStepRole::Preprocess),
583                step("pls", ChainStepRole::Model),
584            ],
585            score,
586            dataset: Some(dataset.to_string()),
587            source: Some("nir".to_string()),
588            evaluation_scope: Some(EvaluationScope::Oof),
589        }
590    }
591
592    fn nrmse() -> ChainEffectMetric {
593        ChainEffectMetric {
594            key: "nrmse".to_string(),
595            label: "nRMSE".to_string(),
596            lower_is_better: true,
597        }
598    }
599
600    #[test]
601    fn role_mapping_is_exhaustive_and_correct() {
602        assert_eq!(
603            chain_role_for_node_kind(&NodeKind::Transform),
604            ChainStepRole::Preprocess
605        );
606        assert_eq!(
607            chain_role_for_node_kind(&NodeKind::YTransform),
608            ChainStepRole::Target
609        );
610        assert_eq!(
611            chain_role_for_node_kind(&NodeKind::Split),
612            ChainStepRole::Split
613        );
614        assert_eq!(
615            chain_role_for_node_kind(&NodeKind::Model),
616            ChainStepRole::Model
617        );
618        assert_eq!(
619            chain_role_for_node_kind(&NodeKind::Augmentation),
620            ChainStepRole::Augmentation
621        );
622        assert_eq!(
623            chain_role_for_node_kind(&NodeKind::FeatureJoin),
624            ChainStepRole::Feature
625        );
626        assert_eq!(
627            chain_role_for_node_kind(&NodeKind::SourceJoin),
628            ChainStepRole::Feature
629        );
630        for kind in [
631            NodeKind::Fork,
632            NodeKind::Map,
633            NodeKind::PredictionJoin,
634            NodeKind::MixedJoin,
635            NodeKind::Tag,
636            NodeKind::Exclude,
637            NodeKind::Adapter,
638            NodeKind::Aggregator,
639            NodeKind::Generator,
640            NodeKind::Restructure,
641            NodeKind::Tuner,
642            NodeKind::Subgraph,
643            NodeKind::Chart,
644        ] {
645            assert_eq!(chain_role_for_node_kind(&kind), ChainStepRole::Other);
646        }
647    }
648
649    #[test]
650    fn percentile_ranks_average_ties() {
651        assert_eq!(percentile_ranks(&[10.0, 20.0, 30.0]), vec![0.0, 0.5, 1.0]);
652        let ties = percentile_ranks(&[5.0, 5.0, 9.0]);
653        assert!((ties[0] - 0.25).abs() < 1e-12);
654        assert!((ties[1] - 0.25).abs() < 1e-12);
655        assert!((ties[2] - 1.0).abs() < 1e-12);
656        assert_eq!(percentile_ranks(&[42.0]), vec![0.5]);
657        assert!(percentile_ranks(&[]).is_empty());
658    }
659
660    #[test]
661    fn z_scores_center_and_handle_zero_variance() {
662        let z = z_scores(&[1.0, 2.0, 3.0]);
663        assert!(z[1].abs() < 1e-12);
664        assert!((z[0] + z[2]).abs() < 1e-12);
665        assert_eq!(z_scores(&[7.0, 7.0, 7.0]), vec![0.0, 0.0, 0.0]);
666        assert_eq!(z_scores(&[5.0]), vec![0.0]);
667        assert!(z_scores(&[]).is_empty());
668    }
669
670    #[test]
671    fn median_handles_odd_and_even() {
672        assert!((median(&[3.0, 1.0, 2.0]) - 2.0).abs() < 1e-12);
673        assert!((median(&[1.0, 2.0, 3.0, 4.0]) - 2.5).abs() < 1e-12);
674        assert!(median(&[]).is_nan());
675    }
676
677    #[test]
678    fn rank_lens_normalizes_per_dataset() {
679        let observations = vec![
680            obs("a", "d1", "snv", 0.10),
681            obs("b", "d1", "msc", 0.20),
682            obs("c", "d2", "snv", 5.00),
683            obs("d", "d2", "msc", 9.00),
684        ];
685        let analysis = ChainEffectAnalysis::from_observations(
686            &observations,
687            nrmse(),
688            ChainEffectLens::RankByDataset,
689        )
690        .unwrap();
691        let goodness = |id: &str| {
692            analysis
693                .points
694                .iter()
695                .find(|point| point.id == id)
696                .unwrap()
697                .goodness
698        };
699        // best (lowest nRMSE) in each dataset → 1.0; worst → 0.0
700        assert!((goodness("a") - 1.0).abs() < 1e-12);
701        assert!((goodness("b") - 0.0).abs() < 1e-12);
702        assert!((goodness("c") - 1.0).abs() < 1e-12);
703        assert!((goodness("d") - 0.0).abs() < 1e-12);
704        assert!((analysis.baseline - 0.5).abs() < 1e-12);
705        assert_eq!(analysis.evaluation_scope, Some(EvaluationScope::Oof));
706    }
707
708    #[test]
709    fn raw_lens_keeps_oriented_score() {
710        let observations = vec![obs("a", "d1", "snv", 0.10)];
711        let analysis =
712            ChainEffectAnalysis::from_observations(&observations, nrmse(), ChainEffectLens::Raw)
713                .unwrap();
714        assert!((analysis.points[0].goodness + 0.10).abs() < 1e-12);
715    }
716
717    #[test]
718    fn preserves_repeated_ordered_tokens() {
719        let observation = ChainObservation {
720            id: "x".to_string(),
721            steps: vec![
722                step("snv", ChainStepRole::Preprocess),
723                step("snv", ChainStepRole::Preprocess),
724                step("pls", ChainStepRole::Model),
725            ],
726            score: 0.1,
727            dataset: None,
728            source: None,
729            evaluation_scope: None,
730        };
731        let analysis =
732            ChainEffectAnalysis::from_observations(&[observation], nrmse(), ChainEffectLens::Raw)
733                .unwrap();
734        assert_eq!(analysis.points[0].ordered_tokens.len(), 3);
735    }
736
737    #[test]
738    fn rejects_invalid_input() {
739        // empty
740        assert!(
741            ChainEffectAnalysis::from_observations(&[], nrmse(), ChainEffectLens::Raw).is_err()
742        );
743        // duplicate id
744        let dup = vec![obs("a", "d1", "snv", 0.1), obs("a", "d1", "msc", 0.2)];
745        assert!(ChainEffectAnalysis::from_observations(
746            &dup,
747            nrmse(),
748            ChainEffectLens::RankByDataset
749        )
750        .is_err());
751        // non-finite score
752        let nan = vec![obs("a", "d1", "snv", f64::NAN)];
753        assert!(
754            ChainEffectAnalysis::from_observations(&nan, nrmse(), ChainEffectLens::Raw).is_err()
755        );
756        // rank lens without dataset
757        let mut no_ds = obs("a", "d1", "snv", 0.1);
758        no_ds.dataset = None;
759        assert!(ChainEffectAnalysis::from_observations(
760            &[no_ds],
761            nrmse(),
762            ChainEffectLens::RankByDataset
763        )
764        .is_err());
765        // mixed scopes
766        let mut holdout = obs("b", "d1", "msc", 0.2);
767        holdout.evaluation_scope = Some(EvaluationScope::Holdout);
768        let mixed = vec![obs("a", "d1", "snv", 0.1), holdout];
769        assert!(ChainEffectAnalysis::from_observations(
770            &mixed,
771            nrmse(),
772            ChainEffectLens::RankByDataset
773        )
774        .is_err());
775    }
776
777    #[test]
778    fn round_trips_and_emits_wire_field_names() {
779        let observations = vec![obs("a", "d1", "snv", 0.10), obs("b", "d1", "msc", 0.20)];
780        let analysis = ChainEffectAnalysis::from_observations(
781            &observations,
782            nrmse(),
783            ChainEffectLens::RankByDataset,
784        )
785        .unwrap();
786        let json = analysis.to_json().unwrap();
787        assert!(json.contains("\"lower_is_better\""));
788        assert!(json.contains("\"ordered_tokens\""));
789        assert!(json.contains("\"rank_by_dataset\""));
790        assert!(json.contains(CHAIN_EFFECT_SCHEMA_ID));
791        let parsed = ChainEffectAnalysis::from_json(&json).unwrap();
792        assert_eq!(parsed, analysis);
793    }
794
795    #[test]
796    fn percentile_ranks_treats_signed_zero_as_tie() {
797        let ranks = percentile_ranks(&[-0.0, 0.0, 1.0]);
798        assert!((ranks[0] - 0.25).abs() < 1e-12);
799        assert!((ranks[1] - 0.25).abs() < 1e-12);
800        assert!((ranks[2] - 1.0).abs() < 1e-12);
801    }
802
803    #[test]
804    fn validate_rejects_inconsistent_goodness_and_baseline() {
805        let observations = vec![obs("a", "d1", "snv", 0.10), obs("b", "d1", "msc", 0.20)];
806        // tampered baseline
807        let mut tampered = ChainEffectAnalysis::from_observations(
808            &observations,
809            nrmse(),
810            ChainEffectLens::RankByDataset,
811        )
812        .unwrap();
813        tampered.baseline = 0.9;
814        assert!(tampered.validate().is_err());
815        // tampered raw goodness (must equal the oriented score)
816        let mut raw = ChainEffectAnalysis::from_observations(
817            &observations[..1],
818            nrmse(),
819            ChainEffectLens::Raw,
820        )
821        .unwrap();
822        raw.points[0].goodness = 42.0;
823        raw.baseline = 42.0;
824        assert!(raw.validate().is_err());
825        // out-of-range rank goodness
826        let mut rank = ChainEffectAnalysis::from_observations(
827            &observations,
828            nrmse(),
829            ChainEffectLens::RankByDataset,
830        )
831        .unwrap();
832        rank.points[0].goodness = 2.0;
833        rank.baseline = median(&rank.points.iter().map(|p| p.goodness).collect::<Vec<_>>());
834        assert!(rank.validate().is_err());
835    }
836
837    #[test]
838    fn from_json_rejects_bad_schema_id_and_version() {
839        let observations = vec![obs("a", "d1", "snv", 0.10)];
840        let mut analysis =
841            ChainEffectAnalysis::from_observations(&observations, nrmse(), ChainEffectLens::Raw)
842                .unwrap();
843        analysis.schema_id = "https://example.com/wrong".to_string();
844        assert!(analysis.validate().is_err());
845        analysis.schema_id = CHAIN_EFFECT_SCHEMA_ID.to_string();
846        analysis.schema_version = 999;
847        assert!(analysis.validate().is_err());
848    }
849
850    #[test]
851    fn published_schema_declares_current_contract() {
852        let schema: serde_json::Value = serde_json::from_str(include_str!(
853            "../../../docs/contracts/chain_effect_analysis.schema.json"
854        ))
855        .unwrap();
856        assert_eq!(schema["$id"], CHAIN_EFFECT_SCHEMA_ID);
857        assert_eq!(
858            schema["additionalProperties"],
859            serde_json::Value::Bool(false)
860        );
861        let required = schema["required"].as_array().unwrap();
862        for field in [
863            "schema_id",
864            "schema_version",
865            "metric",
866            "lens",
867            "baseline",
868            "points",
869        ] {
870            assert!(
871                required.iter().any(|value| value == field),
872                "missing {field}"
873            );
874        }
875    }
876
877    #[test]
878    fn published_fixture_matches_contract() {
879        let fixture = include_str!("../../../examples/fixtures/chain_effect_analysis.json");
880        let analysis = ChainEffectAnalysis::from_json(fixture).unwrap();
881        assert_eq!(analysis.schema_id, CHAIN_EFFECT_SCHEMA_ID);
882        assert!(!analysis.points.is_empty());
883    }
884}