Skip to main content

dag_ml_core/
conformal_runtime.rs

1//! Durable, identity-keyed wiring for the native split-conformal kernel.
2//!
3//! This module deliberately accepts point predictions and truth only.  The
4//! scheduler/controller boundary remains unchanged: a host supplies the
5//! ordinary PREDICT result and DAG-ML validates its stable sample identities,
6//! calibrates with [`crate::conformal`], and can apply the persisted record on
7//! another ordinary PREDICT result.
8
9use std::collections::BTreeSet;
10
11use serde::{Deserialize, Serialize};
12
13use crate::canonical::parse_typed_json;
14use crate::conformal::{
15    apply_split_absolute_residual, finite_sample_conformal_rank, split_absolute_residual_quantiles,
16    ConformalMultiTargetPolicy, ConformalSmallSamplePolicy, RegressionConformalInterval,
17    SplitConformalQuantile,
18};
19use crate::error::{DagMlError, Result};
20use crate::ids::SampleId;
21use crate::oof::PredictionBlock;
22
23/// V1 did not bind calibration to the training/replay provenance closure.  It
24/// is deliberately not accepted: callers must migrate to this closed V2 form.
25pub const CONFORMAL_RUNTIME_SCHEMA_VERSION: u32 = 2;
26
27/// Relation-derived calibration cohort. Physical and origin identities are
28/// both retained so a relation-expanded training cohort cannot be bypassed by
29/// presenting only one namespace. The attachment boundary derives and checks
30/// these fields from an authoritative [`crate::relation::SampleRelationSet`].
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct ConformalCalibrationCohort {
34    pub role: String,
35    pub physical_sample_ids: Vec<SampleId>,
36    pub origin_sample_ids: Vec<SampleId>,
37    pub target_names: Vec<String>,
38    pub manifest_fingerprint: String,
39}
40
41/// Complete, canonical provenance closure supplied by the replay boundary.
42/// These are not optional hints: attached calibration checks every member
43/// against the exact source outcome and replay before it is persisted.
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ConformalCalibrationContext {
47    pub predictor_binding_fingerprint: String,
48    pub source_training_outcome_fingerprint: String,
49    pub calibration_replay_outcome_fingerprint: String,
50    pub data_identities_fingerprint: String,
51    pub fold_set_fingerprint: String,
52    pub training_influence_fingerprint: String,
53    pub relation_fingerprint: String,
54    pub calibration_cohort: ConformalCalibrationCohort,
55    pub context_fingerprint: String,
56}
57
58/// Closed, self-fingerprinted split-conformal state retained beside a bundle.
59/// `sample_ids` is the calibration order, not an interchangeable set: this
60/// makes accidental positional joins fail before residuals are calculated.
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct ConformalCalibration {
64    pub schema_version: u32,
65    pub binding_id: String,
66    pub target_names: Vec<String>,
67    pub sample_ids: Vec<SampleId>,
68    pub coverages: Vec<f64>,
69    pub multi_target_policy: ConformalMultiTargetPolicy,
70    pub small_sample_policy: ConformalSmallSamplePolicy,
71    pub quantiles: Vec<SplitConformalQuantile>,
72    pub context: ConformalCalibrationContext,
73    pub calibration_fingerprint: String,
74}
75
76/// Typed reference retained by portable execution bundles.  It contains no
77/// host object or duplicate algorithm state; the complete state stays in the
78/// matching `TrainingOutcome`.
79#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ConformalCalibrationRef {
82    pub schema_version: u32,
83    pub binding_id: String,
84    pub calibration_fingerprint: String,
85}
86
87/// Identity-preserving interval result for one replayed point block.
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct ConformalIntervalBlock {
91    pub schema_version: u32,
92    pub binding_id: String,
93    pub sample_ids: Vec<SampleId>,
94    pub intervals: Vec<RegressionConformalInterval>,
95    pub calibration_fingerprint: String,
96    pub point_prediction_fingerprint: String,
97}
98
99/// Truth supplied by the data layer for a calibration replay.  It carries the
100/// same stable physical sample ids as the point block so a host can never
101/// smuggle a positional `y_true` matrix across a reordered replay.
102#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct ConformalCalibrationTruth {
105    pub sample_ids: Vec<SampleId>,
106    pub values: Vec<Vec<f64>>,
107}
108
109impl ConformalIntervalBlock {
110    pub fn validate(&self) -> Result<()> {
111        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
112            || self.binding_id.trim().is_empty()
113        {
114            return Err(DagMlError::RuntimeValidation(
115                "conformal interval block has an unsupported version or empty binding id"
116                    .to_string(),
117            ));
118        }
119        validate_unique_samples(&self.sample_ids)?;
120        if self.intervals.is_empty()
121            || self
122                .intervals
123                .iter()
124                .any(|interval| interval.cells.len() != self.sample_ids.len())
125        {
126            return Err(DagMlError::RuntimeValidation(
127                "conformal interval block does not cover its exact sample ids".to_string(),
128            ));
129        }
130        validate_sha256(&self.calibration_fingerprint)?;
131        validate_sha256(&self.point_prediction_fingerprint)
132    }
133}
134
135impl ConformalCalibration {
136    #[allow(clippy::too_many_arguments)]
137    pub fn calibrate_with_truth(
138        binding_id: impl Into<String>,
139        target_names: Vec<String>,
140        predictions: &PredictionBlock,
141        truth: &ConformalCalibrationTruth,
142        context: ConformalCalibrationContext,
143        coverages: Vec<f64>,
144        multi_target_policy: ConformalMultiTargetPolicy,
145        small_sample_policy: ConformalSmallSamplePolicy,
146    ) -> Result<Self> {
147        predictions.validate_content()?;
148        validate_identity_aligned_truth(predictions, truth)?;
149        context.validate_for_truth(truth, &target_names)?;
150        if target_names.len() != predictions.values[0].len()
151            || (!predictions.target_names.is_empty() && predictions.target_names != target_names)
152        {
153            return Err(DagMlError::RuntimeValidation(
154                "conformal target order does not match the point prediction binding".to_string(),
155            ));
156        }
157        let residuals = predictions
158            .values
159            .iter()
160            .zip(&truth.values)
161            .map(|(prediction, actual)| {
162                prediction
163                    .iter()
164                    .zip(actual)
165                    .map(|(point, value)| (point - value).abs())
166                    .collect::<Vec<_>>()
167            })
168            .collect::<Vec<_>>();
169        let quantiles = split_absolute_residual_quantiles(
170            &residuals,
171            &coverages,
172            multi_target_policy,
173            small_sample_policy,
174        )
175        .map_err(|error| {
176            DagMlError::RuntimeValidation(format!("conformal calibration failed: {error}"))
177        })?;
178        let mut calibration = Self {
179            schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
180            binding_id: binding_id.into(),
181            target_names,
182            sample_ids: predictions.sample_ids.clone(),
183            coverages,
184            multi_target_policy,
185            small_sample_policy,
186            quantiles,
187            context,
188            calibration_fingerprint: String::new(),
189        };
190        calibration.calibration_fingerprint = calibration.compute_fingerprint()?;
191        calibration.validate()?;
192        Ok(calibration)
193    }
194
195    pub fn reference(&self) -> Result<ConformalCalibrationRef> {
196        self.validate()?;
197        Ok(ConformalCalibrationRef {
198            schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
199            binding_id: self.binding_id.clone(),
200            calibration_fingerprint: self.calibration_fingerprint.clone(),
201        })
202    }
203
204    pub fn compute_fingerprint(&self) -> Result<String> {
205        fingerprint_without(self, "calibration_fingerprint", "conformal calibration")
206    }
207
208    pub fn from_json(json: &str) -> Result<Self> {
209        let raw = parse_typed_json(json)
210            .and_then(|value| value.fingerprint_without("calibration_fingerprint"))
211            .map_err(|error| {
212                DagMlError::RuntimeValidation(format!(
213                    "conformal calibration is not strict TCV1 JSON: {error}"
214                ))
215            })?;
216        let calibration: Self = serde_json::from_str(json)?;
217        if calibration.calibration_fingerprint != raw {
218            return Err(DagMlError::RuntimeValidation(
219                "conformal calibration fingerprint does not match original TCV1 JSON".to_string(),
220            ));
221        }
222        calibration.validate()?;
223        Ok(calibration)
224    }
225
226    pub fn validate(&self) -> Result<()> {
227        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION {
228            return Err(DagMlError::RuntimeValidation(format!(
229                "conformal calibration has unsupported schema_version {}",
230                self.schema_version
231            )));
232        }
233        if self.binding_id.trim().is_empty() || self.target_names.is_empty() {
234            return Err(DagMlError::RuntimeValidation(
235                "conformal calibration requires a binding id and target names".to_string(),
236            ));
237        }
238        validate_unique_samples(&self.sample_ids)?;
239        self.context.validate_for_calibration(self)?;
240        if self.coverages.is_empty() || self.quantiles.len() != self.coverages.len() {
241            return Err(DagMlError::RuntimeValidation(
242                "conformal calibration coverages and quantiles must have equal non-zero length"
243                    .to_string(),
244            ));
245        }
246        if self
247            .quantiles
248            .iter()
249            .zip(&self.coverages)
250            .any(|(quantile, coverage)| quantile.coverage.to_bits() != coverage.to_bits())
251        {
252            return Err(DagMlError::RuntimeValidation(
253                "conformal calibration quantile coverage order does not match coverages"
254                    .to_string(),
255            ));
256        }
257        let sample_count = u64::try_from(self.sample_ids.len()).map_err(|_| {
258            DagMlError::RuntimeValidation(
259                "conformal calibration sample count exceeds u64".to_string(),
260            )
261        })?;
262        for (index, (coverage, quantile)) in self.coverages.iter().zip(&self.quantiles).enumerate()
263        {
264            let expected =
265                finite_sample_conformal_rank(sample_count, *coverage).map_err(|error| {
266                    DagMlError::RuntimeValidation(format!(
267                        "invalid conformal rank at coverage {index}: {error}"
268                    ))
269                })?;
270            if quantile.rank != expected {
271                return Err(DagMlError::RuntimeValidation(format!(
272                    "conformal quantile rank at coverage {index} does not match sample count and coverage"
273                )));
274            }
275        }
276        // The kernel validates coverage ordering, radius shape, and nestedness
277        // before application; applying to one finite dummy row is a compact
278        // validation that does not introduce another conformal algorithm.
279        apply_split_absolute_residual(
280            &[vec![0.0; self.target_names.len()]],
281            &self.quantiles,
282            self.multi_target_policy,
283        )
284        .map_err(|error| {
285            DagMlError::RuntimeValidation(format!("invalid conformal quantiles: {error}"))
286        })?;
287        validate_sha256(&self.calibration_fingerprint)?;
288        if self.calibration_fingerprint != self.compute_fingerprint()? {
289            return Err(DagMlError::RuntimeValidation(
290                "conformal calibration fingerprint does not match TCV1 content".to_string(),
291            ));
292        }
293        Ok(())
294    }
295
296    pub fn apply(&self, predictions: &PredictionBlock) -> Result<ConformalIntervalBlock> {
297        self.validate()?;
298        predictions.validate_content()?;
299        if predictions.target_names != self.target_names {
300            return Err(DagMlError::RuntimeValidation(
301                "conformal application target order does not match calibration".to_string(),
302            ));
303        }
304        let intervals = apply_split_absolute_residual(
305            &predictions.values,
306            &self.quantiles,
307            self.multi_target_policy,
308        )
309        .map_err(|error| {
310            DagMlError::RuntimeValidation(format!("conformal application failed: {error}"))
311        })?;
312        Ok(ConformalIntervalBlock {
313            schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
314            binding_id: self.binding_id.clone(),
315            sample_ids: predictions.sample_ids.clone(),
316            intervals,
317            calibration_fingerprint: self.calibration_fingerprint.clone(),
318            point_prediction_fingerprint: point_prediction_fingerprint_for_runtime(predictions)?,
319        })
320    }
321}
322
323impl ConformalCalibrationContext {
324    pub fn compute_fingerprint(&self) -> Result<String> {
325        fingerprint_without(self, "context_fingerprint", "conformal calibration context")
326    }
327
328    pub fn validate_for_truth(
329        &self,
330        truth: &ConformalCalibrationTruth,
331        target_names: &[String],
332    ) -> Result<()> {
333        self.validate()?;
334        if self.calibration_cohort.physical_sample_ids != truth.sample_ids
335            || self.calibration_cohort.target_names != target_names
336        {
337            return Err(DagMlError::RuntimeValidation(
338                "conformal calibration cohort must exactly bind truth sample ids and targets"
339                    .to_string(),
340            ));
341        }
342        Ok(())
343    }
344
345    pub fn validate(&self) -> Result<()> {
346        for value in [
347            &self.predictor_binding_fingerprint,
348            &self.source_training_outcome_fingerprint,
349            &self.calibration_replay_outcome_fingerprint,
350            &self.data_identities_fingerprint,
351            &self.fold_set_fingerprint,
352            &self.training_influence_fingerprint,
353            &self.relation_fingerprint,
354            &self.context_fingerprint,
355        ] {
356            validate_sha256(value)?;
357        }
358        self.calibration_cohort.validate()?;
359        if self.context_fingerprint != self.compute_fingerprint()? {
360            return Err(DagMlError::RuntimeValidation(
361                "conformal calibration context fingerprint does not match TCV1 content".to_string(),
362            ));
363        }
364        Ok(())
365    }
366
367    fn validate_for_calibration(&self, calibration: &ConformalCalibration) -> Result<()> {
368        self.validate_for_truth(
369            &ConformalCalibrationTruth {
370                sample_ids: calibration.sample_ids.clone(),
371                values: vec![vec![0.0]; calibration.sample_ids.len()],
372            },
373            &calibration.target_names,
374        )
375    }
376}
377
378impl ConformalCalibrationCohort {
379    pub fn compute_fingerprint(&self) -> Result<String> {
380        fingerprint_without(self, "manifest_fingerprint", "conformal calibration cohort")
381    }
382
383    pub fn validate(&self) -> Result<()> {
384        validate_sha256(&self.manifest_fingerprint)?;
385        if self.role != "calibration" || self.target_names.is_empty() {
386            return Err(DagMlError::RuntimeValidation(
387                "conformal calibration context requires calibration cohort role and targets"
388                    .to_string(),
389            ));
390        }
391        validate_unique_samples(&self.physical_sample_ids)?;
392        if self.origin_sample_ids.iter().collect::<BTreeSet<_>>().len()
393            != self.origin_sample_ids.len()
394        {
395            return Err(DagMlError::RuntimeValidation(
396                "conformal calibration origin sample ids must be unique".to_string(),
397            ));
398        }
399        if self.manifest_fingerprint != self.compute_fingerprint()? {
400            return Err(DagMlError::RuntimeValidation(
401                "conformal calibration cohort fingerprint does not match TCV1 content".to_string(),
402            ));
403        }
404        Ok(())
405    }
406}
407
408impl ConformalIntervalBlock {
409    /// Validate interval closure against the actual point block and quantiles;
410    /// a matching hash alone is never treated as sufficient.
411    pub fn validate_against(
412        &self,
413        calibration: &ConformalCalibration,
414        predictions: &PredictionBlock,
415    ) -> Result<()> {
416        self.validate()?;
417        calibration.validate()?;
418        if self.binding_id != calibration.binding_id
419            || self.calibration_fingerprint != calibration.calibration_fingerprint
420            || self.sample_ids != predictions.sample_ids
421            || self.point_prediction_fingerprint
422                != point_prediction_fingerprint_for_runtime(predictions)?
423        {
424            return Err(DagMlError::RuntimeValidation("conformal interval block is not bound to its calibration and point prediction block".to_string()));
425        }
426        let expected = calibration.apply(predictions)?;
427        if self != &expected {
428            return Err(DagMlError::RuntimeValidation(
429                "conformal interval bounds do not close over point predictions and quantiles"
430                    .to_string(),
431            ));
432        }
433        Ok(())
434    }
435}
436
437impl ConformalCalibrationRef {
438    pub fn validate(&self) -> Result<()> {
439        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
440            || self.binding_id.trim().is_empty()
441        {
442            return Err(DagMlError::RuntimeValidation(
443                "conformal calibration reference has an unsupported version or empty binding id"
444                    .to_string(),
445            ));
446        }
447        validate_sha256(&self.calibration_fingerprint)
448    }
449
450    pub fn validate_against(&self, calibration: &ConformalCalibration) -> Result<()> {
451        self.validate()?;
452        calibration.validate()?;
453        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
454            || self.binding_id != calibration.binding_id
455            || self.calibration_fingerprint != calibration.calibration_fingerprint
456        {
457            return Err(DagMlError::RuntimeValidation(
458                "conformal calibration reference does not match calibration state".to_string(),
459            ));
460        }
461        Ok(())
462    }
463}
464
465fn validate_identity_aligned_truth(
466    predictions: &PredictionBlock,
467    truth: &ConformalCalibrationTruth,
468) -> Result<()> {
469    if predictions.sample_ids != truth.sample_ids
470        || predictions.values.len() != truth.values.len()
471        || truth.values.is_empty()
472        || truth
473            .values
474            .iter()
475            .any(|row| row.len() != predictions.values[0].len())
476        || truth
477            .values
478            .iter()
479            .flatten()
480            .any(|value| !value.is_finite())
481    {
482        return Err(DagMlError::RuntimeValidation(
483            "conformal truth must be finite and exactly row/target aligned by sample id"
484                .to_string(),
485        ));
486    }
487    Ok(())
488}
489
490fn validate_unique_samples(sample_ids: &[SampleId]) -> Result<()> {
491    if sample_ids.is_empty() || sample_ids.iter().collect::<BTreeSet<_>>().len() != sample_ids.len()
492    {
493        return Err(DagMlError::RuntimeValidation(
494            "conformal calibration requires non-empty unique sample ids".to_string(),
495        ));
496    }
497    Ok(())
498}
499
500fn fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
501    let json = serde_json::to_string(value)?;
502    parse_typed_json(&json)
503        .and_then(|typed| typed.fingerprint_without(field))
504        .map_err(|error| DagMlError::RuntimeValidation(format!("{label} is outside TCV1: {error}")))
505}
506
507pub(crate) fn point_prediction_fingerprint_for_runtime(
508    predictions: &PredictionBlock,
509) -> Result<String> {
510    predictions.validate_content()?;
511    fingerprint_without(predictions, "prediction_id", "conformal point prediction")
512}
513
514fn validate_sha256(value: &str) -> Result<()> {
515    if value.len() != 64
516        || !value
517            .bytes()
518            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
519    {
520        return Err(DagMlError::RuntimeValidation(
521            "conformal calibration fingerprint must be lowercase SHA-256".to_string(),
522        ));
523    }
524    Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::ids::NodeId;
531    use crate::oof::PredictionPartition;
532
533    fn block(ids: &[&str], values: &[f64]) -> PredictionBlock {
534        PredictionBlock {
535            prediction_id: None,
536            producer_node: NodeId::new("model:regressor").unwrap(),
537            producer_port: Some("prediction".to_string()),
538            partition: PredictionPartition::Validation,
539            fold_id: None,
540            sample_ids: ids.iter().map(|id| SampleId::new(*id).unwrap()).collect(),
541            values: values.iter().map(|value| vec![*value]).collect(),
542            target_names: vec!["y".to_string()],
543        }
544    }
545
546    fn context(ids: Vec<SampleId>, targets: Vec<String>) -> ConformalCalibrationContext {
547        let mut cohort = ConformalCalibrationCohort {
548            role: "calibration".to_string(),
549            physical_sample_ids: ids.clone(),
550            origin_sample_ids: ids,
551            target_names: targets,
552            manifest_fingerprint: String::new(),
553        };
554        cohort.manifest_fingerprint = cohort.compute_fingerprint().unwrap();
555        let mut context = ConformalCalibrationContext {
556            predictor_binding_fingerprint: "1".repeat(64),
557            source_training_outcome_fingerprint: "2".repeat(64),
558            calibration_replay_outcome_fingerprint: "3".repeat(64),
559            data_identities_fingerprint: "4".repeat(64),
560            fold_set_fingerprint: "5".repeat(64),
561            training_influence_fingerprint: "6".repeat(64),
562            relation_fingerprint: "7".repeat(64),
563            calibration_cohort: cohort,
564            context_fingerprint: String::new(),
565        };
566        context.context_fingerprint = context.compute_fingerprint().unwrap();
567        context
568    }
569
570    #[test]
571    fn calibration_round_trips_and_application_preserves_replay_ids() {
572        let calibration = ConformalCalibration::calibrate_with_truth(
573            "output:main",
574            vec!["y".to_string()],
575            &block(&["s1", "s2", "s3"], &[1.0, 3.0, 5.0]),
576            &ConformalCalibrationTruth {
577                sample_ids: vec![
578                    SampleId::new("s1").unwrap(),
579                    SampleId::new("s2").unwrap(),
580                    SampleId::new("s3").unwrap(),
581                ],
582                values: vec![vec![0.0], vec![2.0], vec![4.0]],
583            },
584            context(
585                vec![
586                    SampleId::new("s1").unwrap(),
587                    SampleId::new("s2").unwrap(),
588                    SampleId::new("s3").unwrap(),
589                ],
590                vec!["y".to_string()],
591            ),
592            vec![0.5],
593            ConformalMultiTargetPolicy::Marginal,
594            ConformalSmallSamplePolicy::Error,
595        )
596        .unwrap();
597        let json = serde_json::to_string(&calibration).unwrap();
598        let loaded = ConformalCalibration::from_json(&json).unwrap();
599        let replay = block(&["new:2", "new:1"], &[10.0, 20.0]);
600        let intervals = loaded.apply(&replay).unwrap();
601        assert_eq!(intervals.sample_ids, replay.sample_ids);
602        assert_eq!(intervals.intervals.len(), 1);
603        let cell = intervals.intervals[0].cells[0][0];
604        assert_eq!(cell.endpoints(), (Some(9.0), Some(11.0)));
605    }
606
607    #[test]
608    fn calibration_refuses_order_and_tamper() {
609        let prediction = block(&["s1", "s2"], &[1.0, 2.0]);
610        assert!(ConformalCalibration::calibrate_with_truth(
611            "output:main",
612            vec!["y".to_string()],
613            &prediction,
614            &ConformalCalibrationTruth {
615                sample_ids: vec![SampleId::new("s2").unwrap(), SampleId::new("s1").unwrap()],
616                values: vec![vec![1.0], vec![0.0]],
617            },
618            context(prediction.sample_ids.clone(), vec!["y".to_string()]),
619            vec![0.5],
620            ConformalMultiTargetPolicy::Marginal,
621            ConformalSmallSamplePolicy::Error,
622        )
623        .is_err());
624        assert!(ConformalCalibration::calibrate_with_truth(
625            "output:main",
626            vec!["wrong".to_string()],
627            &prediction,
628            &ConformalCalibrationTruth {
629                sample_ids: prediction.sample_ids.clone(),
630                values: vec![vec![0.0], vec![1.0]]
631            },
632            context(prediction.sample_ids.clone(), vec!["wrong".to_string()]),
633            vec![0.5],
634            ConformalMultiTargetPolicy::Marginal,
635            ConformalSmallSamplePolicy::Error
636        )
637        .is_err());
638        let calibration = ConformalCalibration::calibrate_with_truth(
639            "output:main",
640            vec!["y".to_string()],
641            &prediction,
642            &ConformalCalibrationTruth {
643                sample_ids: prediction.sample_ids.clone(),
644                values: vec![vec![0.0], vec![1.0]],
645            },
646            context(prediction.sample_ids.clone(), vec!["y".to_string()]),
647            vec![0.5],
648            ConformalMultiTargetPolicy::Marginal,
649            ConformalSmallSamplePolicy::Error,
650        )
651        .unwrap();
652        let mut value = serde_json::to_value(calibration).unwrap();
653        value["quantiles"][0]["rank"] = serde_json::json!(1);
654        let mut resigned: ConformalCalibration = serde_json::from_value(value.clone()).unwrap();
655        resigned.calibration_fingerprint = resigned.compute_fingerprint().unwrap();
656        value = serde_json::to_value(resigned).unwrap();
657        assert!(ConformalCalibration::from_json(&value.to_string()).is_err());
658    }
659
660    #[test]
661    fn v2_context_is_required_and_interval_bounds_close_over_points() {
662        let prediction = block(&["cal:1", "cal:2"], &[3.0, 7.0]);
663        let truth = ConformalCalibrationTruth {
664            sample_ids: prediction.sample_ids.clone(),
665            values: vec![vec![2.0], vec![5.0]],
666        };
667        let calibration = ConformalCalibration::calibrate_with_truth(
668            "output:main",
669            vec!["y".to_string()],
670            &prediction,
671            &truth,
672            context(prediction.sample_ids.clone(), vec!["y".to_string()]),
673            vec![0.5],
674            ConformalMultiTargetPolicy::Marginal,
675            ConformalSmallSamplePolicy::Error,
676        )
677        .unwrap();
678        let replay = block(&["replay:1"], &[10.0]);
679        let mut intervals = calibration.apply(&replay).unwrap();
680        intervals.validate_against(&calibration, &replay).unwrap();
681        intervals.intervals[0].coverage = 0.8;
682        assert!(intervals.validate_against(&calibration, &replay).is_err());
683
684        let mut v1 = serde_json::to_value(&calibration).unwrap();
685        v1["schema_version"] = serde_json::json!(1);
686        assert!(ConformalCalibration::from_json(&v1.to_string()).is_err());
687        let mut missing_context = serde_json::to_value(&calibration).unwrap();
688        missing_context.as_object_mut().unwrap().remove("context");
689        assert!(ConformalCalibration::from_json(&missing_context.to_string()).is_err());
690    }
691}