1use 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
38pub const CHAIN_EFFECT_SCHEMA_ID: &str =
40 "https://github.com/GBeurier/dag-ml/schemas/chain_effect_analysis.v1.schema.json";
41
42pub const CHAIN_EFFECT_SCHEMA_VERSION: u32 = 1;
44
45#[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
59pub 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum ChainEffectLens {
93 Raw,
95 RankByDataset,
97 ZByDataset,
99}
100
101impl ChainEffectLens {
102 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 pub fn requires_dataset(self) -> bool {
113 matches!(self, Self::RankByDataset | Self::ZByDataset)
114 }
115}
116
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ChainEffectMetric {
121 pub key: String,
122 pub label: String,
123 pub lower_is_better: bool,
125}
126
127impl ChainEffectMetric {
128 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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct ChainObservation {
167 pub id: String,
168 pub steps: Vec<ChainEffectStep>,
170 pub score: f64,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub dataset: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub source: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub evaluation_scope: Option<EvaluationScope>,
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct ChainEffectPoint {
186 pub id: String,
187 pub score: f64,
189 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 pub ordered_tokens: Vec<ChainEffectStep>,
197}
198
199#[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 pub baseline: f64,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub evaluation_scope: Option<EvaluationScope>,
212 pub points: Vec<ChainEffectPoint>,
213}
214
215impl ChainEffectAnalysis {
216 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 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 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 pub fn to_json(&self) -> Result<String> {
363 Ok(serde_json::to_string(self)?)
364 }
365
366 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 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
472const RANK_EPS: f64 = 1e-9;
474
475fn 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
487pub fn orient(score: f64, lower_is_better: bool) -> f64 {
489 if lower_is_better {
490 -score
491 } else {
492 score
493 }
494}
495
496pub 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 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
529pub 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
547pub 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 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 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 assert!(
741 ChainEffectAnalysis::from_observations(&[], nrmse(), ChainEffectLens::Raw).is_err()
742 );
743 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 let nan = vec![obs("a", "d1", "snv", f64::NAN)];
753 assert!(
754 ChainEffectAnalysis::from_observations(&nan, nrmse(), ChainEffectLens::Raw).is_err()
755 );
756 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 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 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 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 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}