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 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        stabilize_calibration_for_tcv1(calibration)
191    }
192
193    pub fn reference(&self) -> Result<ConformalCalibrationRef> {
194        self.validate()?;
195        Ok(ConformalCalibrationRef {
196            schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
197            binding_id: self.binding_id.clone(),
198            calibration_fingerprint: self.calibration_fingerprint.clone(),
199        })
200    }
201
202    pub fn compute_fingerprint(&self) -> Result<String> {
203        fingerprint_without(self, "calibration_fingerprint", "conformal calibration")
204    }
205
206    pub fn from_json(json: &str) -> Result<Self> {
207        let raw = parse_typed_json(json)
208            .and_then(|value| value.fingerprint_without("calibration_fingerprint"))
209            .map_err(|error| {
210                DagMlError::RuntimeValidation(format!(
211                    "conformal calibration is not strict TCV1 JSON: {error}"
212                ))
213            })?;
214        let calibration: Self = serde_json::from_str(json)?;
215        if calibration.calibration_fingerprint != raw {
216            return Err(DagMlError::RuntimeValidation(
217                "conformal calibration fingerprint does not match original TCV1 JSON".to_string(),
218            ));
219        }
220        calibration.validate()?;
221        Ok(calibration)
222    }
223
224    pub fn validate(&self) -> Result<()> {
225        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION {
226            return Err(DagMlError::RuntimeValidation(format!(
227                "conformal calibration has unsupported schema_version {}",
228                self.schema_version
229            )));
230        }
231        if self.binding_id.trim().is_empty() || self.target_names.is_empty() {
232            return Err(DagMlError::RuntimeValidation(
233                "conformal calibration requires a binding id and target names".to_string(),
234            ));
235        }
236        validate_unique_samples(&self.sample_ids)?;
237        self.context.validate_for_calibration(self)?;
238        if self.coverages.is_empty() || self.quantiles.len() != self.coverages.len() {
239            return Err(DagMlError::RuntimeValidation(
240                "conformal calibration coverages and quantiles must have equal non-zero length"
241                    .to_string(),
242            ));
243        }
244        if self
245            .quantiles
246            .iter()
247            .zip(&self.coverages)
248            .any(|(quantile, coverage)| quantile.coverage.to_bits() != coverage.to_bits())
249        {
250            return Err(DagMlError::RuntimeValidation(
251                "conformal calibration quantile coverage order does not match coverages"
252                    .to_string(),
253            ));
254        }
255        let sample_count = u64::try_from(self.sample_ids.len()).map_err(|_| {
256            DagMlError::RuntimeValidation(
257                "conformal calibration sample count exceeds u64".to_string(),
258            )
259        })?;
260        for (index, (coverage, quantile)) in self.coverages.iter().zip(&self.quantiles).enumerate()
261        {
262            let expected =
263                finite_sample_conformal_rank(sample_count, *coverage).map_err(|error| {
264                    DagMlError::RuntimeValidation(format!(
265                        "invalid conformal rank at coverage {index}: {error}"
266                    ))
267                })?;
268            if quantile.rank != expected {
269                return Err(DagMlError::RuntimeValidation(format!(
270                    "conformal quantile rank at coverage {index} does not match sample count and coverage"
271                )));
272            }
273        }
274        // The kernel validates coverage ordering, radius shape, and nestedness
275        // before application; applying to one finite dummy row is a compact
276        // validation that does not introduce another conformal algorithm.
277        apply_split_absolute_residual(
278            &[vec![0.0; self.target_names.len()]],
279            &self.quantiles,
280            self.multi_target_policy,
281        )
282        .map_err(|error| {
283            DagMlError::RuntimeValidation(format!("invalid conformal quantiles: {error}"))
284        })?;
285        validate_sha256(&self.calibration_fingerprint)?;
286        if self.calibration_fingerprint != self.compute_fingerprint()? {
287            return Err(DagMlError::RuntimeValidation(
288                "conformal calibration fingerprint does not match TCV1 content".to_string(),
289            ));
290        }
291        Ok(())
292    }
293
294    pub fn apply(&self, predictions: &PredictionBlock) -> Result<ConformalIntervalBlock> {
295        self.validate()?;
296        predictions.validate_content()?;
297        if predictions.target_names != self.target_names {
298            return Err(DagMlError::RuntimeValidation(
299                "conformal application target order does not match calibration".to_string(),
300            ));
301        }
302        let intervals = apply_split_absolute_residual(
303            &predictions.values,
304            &self.quantiles,
305            self.multi_target_policy,
306        )
307        .map_err(|error| {
308            DagMlError::RuntimeValidation(format!("conformal application failed: {error}"))
309        })?;
310        Ok(ConformalIntervalBlock {
311            schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
312            binding_id: self.binding_id.clone(),
313            sample_ids: predictions.sample_ids.clone(),
314            intervals,
315            calibration_fingerprint: self.calibration_fingerprint.clone(),
316            point_prediction_fingerprint: point_prediction_fingerprint_for_runtime(predictions)?,
317        })
318    }
319}
320
321impl ConformalCalibrationContext {
322    pub fn compute_fingerprint(&self) -> Result<String> {
323        fingerprint_without(self, "context_fingerprint", "conformal calibration context")
324    }
325
326    pub fn validate_for_truth(
327        &self,
328        truth: &ConformalCalibrationTruth,
329        target_names: &[String],
330    ) -> Result<()> {
331        self.validate()?;
332        if self.calibration_cohort.physical_sample_ids != truth.sample_ids
333            || self.calibration_cohort.target_names != target_names
334        {
335            return Err(DagMlError::RuntimeValidation(
336                "conformal calibration cohort must exactly bind truth sample ids and targets"
337                    .to_string(),
338            ));
339        }
340        Ok(())
341    }
342
343    pub fn validate(&self) -> Result<()> {
344        for value in [
345            &self.predictor_binding_fingerprint,
346            &self.source_training_outcome_fingerprint,
347            &self.calibration_replay_outcome_fingerprint,
348            &self.data_identities_fingerprint,
349            &self.fold_set_fingerprint,
350            &self.training_influence_fingerprint,
351            &self.relation_fingerprint,
352            &self.context_fingerprint,
353        ] {
354            validate_sha256(value)?;
355        }
356        self.calibration_cohort.validate()?;
357        if self.context_fingerprint != self.compute_fingerprint()? {
358            return Err(DagMlError::RuntimeValidation(
359                "conformal calibration context fingerprint does not match TCV1 content".to_string(),
360            ));
361        }
362        Ok(())
363    }
364
365    fn validate_for_calibration(&self, calibration: &ConformalCalibration) -> Result<()> {
366        self.validate_for_truth(
367            &ConformalCalibrationTruth {
368                sample_ids: calibration.sample_ids.clone(),
369                values: vec![vec![0.0]; calibration.sample_ids.len()],
370            },
371            &calibration.target_names,
372        )
373    }
374}
375
376impl ConformalCalibrationCohort {
377    pub fn compute_fingerprint(&self) -> Result<String> {
378        fingerprint_without(self, "manifest_fingerprint", "conformal calibration cohort")
379    }
380
381    pub fn validate(&self) -> Result<()> {
382        validate_sha256(&self.manifest_fingerprint)?;
383        if self.role != "calibration" || self.target_names.is_empty() {
384            return Err(DagMlError::RuntimeValidation(
385                "conformal calibration context requires calibration cohort role and targets"
386                    .to_string(),
387            ));
388        }
389        validate_unique_samples(&self.physical_sample_ids)?;
390        if self.origin_sample_ids.iter().collect::<BTreeSet<_>>().len()
391            != self.origin_sample_ids.len()
392        {
393            return Err(DagMlError::RuntimeValidation(
394                "conformal calibration origin sample ids must be unique".to_string(),
395            ));
396        }
397        if self.manifest_fingerprint != self.compute_fingerprint()? {
398            return Err(DagMlError::RuntimeValidation(
399                "conformal calibration cohort fingerprint does not match TCV1 content".to_string(),
400            ));
401        }
402        Ok(())
403    }
404}
405
406impl ConformalIntervalBlock {
407    /// Validate interval closure against the actual point block and quantiles;
408    /// a matching hash alone is never treated as sufficient.
409    pub fn validate_against(
410        &self,
411        calibration: &ConformalCalibration,
412        predictions: &PredictionBlock,
413    ) -> Result<()> {
414        self.validate()?;
415        calibration.validate()?;
416        if self.binding_id != calibration.binding_id
417            || self.calibration_fingerprint != calibration.calibration_fingerprint
418            || self.sample_ids != predictions.sample_ids
419            || self.point_prediction_fingerprint
420                != point_prediction_fingerprint_for_runtime(predictions)?
421        {
422            return Err(DagMlError::RuntimeValidation("conformal interval block is not bound to its calibration and point prediction block".to_string()));
423        }
424        let expected = calibration.apply(predictions)?;
425        if self != &expected {
426            return Err(DagMlError::RuntimeValidation(
427                "conformal interval bounds do not close over point predictions and quantiles"
428                    .to_string(),
429            ));
430        }
431        Ok(())
432    }
433}
434
435impl ConformalCalibrationRef {
436    pub fn validate(&self) -> Result<()> {
437        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
438            || self.binding_id.trim().is_empty()
439        {
440            return Err(DagMlError::RuntimeValidation(
441                "conformal calibration reference has an unsupported version or empty binding id"
442                    .to_string(),
443            ));
444        }
445        validate_sha256(&self.calibration_fingerprint)
446    }
447
448    pub fn validate_against(&self, calibration: &ConformalCalibration) -> Result<()> {
449        self.validate()?;
450        calibration.validate()?;
451        if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
452            || self.binding_id != calibration.binding_id
453            || self.calibration_fingerprint != calibration.calibration_fingerprint
454        {
455            return Err(DagMlError::RuntimeValidation(
456                "conformal calibration reference does not match calibration state".to_string(),
457            ));
458        }
459        Ok(())
460    }
461}
462
463fn validate_identity_aligned_truth(
464    predictions: &PredictionBlock,
465    truth: &ConformalCalibrationTruth,
466) -> Result<()> {
467    if predictions.sample_ids != truth.sample_ids
468        || predictions.values.len() != truth.values.len()
469        || truth.values.is_empty()
470        || truth
471            .values
472            .iter()
473            .any(|row| row.len() != predictions.values[0].len())
474        || truth
475            .values
476            .iter()
477            .flatten()
478            .any(|value| !value.is_finite())
479    {
480        return Err(DagMlError::RuntimeValidation(
481            "conformal truth must be finite and exactly row/target aligned by sample id"
482                .to_string(),
483        ));
484    }
485    Ok(())
486}
487
488fn validate_unique_samples(sample_ids: &[SampleId]) -> Result<()> {
489    if sample_ids.is_empty() || sample_ids.iter().collect::<BTreeSet<_>>().len() != sample_ids.len()
490    {
491        return Err(DagMlError::RuntimeValidation(
492            "conformal calibration requires non-empty unique sample ids".to_string(),
493        ));
494    }
495    Ok(())
496}
497
498fn fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
499    let json = serde_json::to_string(value)?;
500    parse_typed_json(&json)
501        .and_then(|typed| typed.fingerprint_without(field))
502        .map_err(|error| DagMlError::RuntimeValidation(format!("{label} is outside TCV1: {error}")))
503}
504
505fn stabilize_calibration_for_tcv1(
506    mut calibration: ConformalCalibration,
507) -> Result<ConformalCalibration> {
508    // TCV1 fingerprints the lexical binary64 token.  A radius produced by
509    // native arithmetic can need one serde round-trip before that token is the
510    // same one a strict JSON reader will observe.  Sign only that fixed point;
511    // otherwise a newly created calibration can reject its own serialized form.
512    calibration.calibration_fingerprint = "0".repeat(64);
513    for _ in 0..8 {
514        let json = serde_json::to_string(&calibration)?;
515        let before = parse_typed_json(&json).map_err(|error| {
516            DagMlError::RuntimeValidation(format!(
517                "conformal calibration is outside TCV1 while normalizing: {error}"
518            ))
519        })?;
520        let mut normalized = serde_json::from_str::<ConformalCalibration>(&json)?;
521        normalized.calibration_fingerprint = "0".repeat(64);
522        let normalized_json = serde_json::to_string(&normalized)?;
523        let after = parse_typed_json(&normalized_json).map_err(|error| {
524            DagMlError::RuntimeValidation(format!(
525                "conformal calibration is outside TCV1 after normalization: {error}"
526            ))
527        })?;
528        if before != after {
529            calibration = normalized;
530            continue;
531        }
532        normalized.calibration_fingerprint = after
533            .fingerprint_without("calibration_fingerprint")
534            .map_err(|error| {
535            DagMlError::RuntimeValidation(format!(
536                "conformal calibration TCV1 fingerprint failed after normalization: {error}"
537            ))
538        })?;
539        let signed_json = serde_json::to_string(&normalized)?;
540        return ConformalCalibration::from_json(&signed_json);
541    }
542    Err(DagMlError::RuntimeValidation(
543        "conformal calibration TCV1 JSON did not reach a serde canonical fixed point".to_string(),
544    ))
545}
546
547pub(crate) fn point_prediction_fingerprint_for_runtime(
548    predictions: &PredictionBlock,
549) -> Result<String> {
550    predictions.validate_content()?;
551    fingerprint_without(predictions, "prediction_id", "conformal point prediction")
552}
553
554fn validate_sha256(value: &str) -> Result<()> {
555    if value.len() != 64
556        || !value
557            .bytes()
558            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
559    {
560        return Err(DagMlError::RuntimeValidation(
561            "conformal calibration fingerprint must be lowercase SHA-256".to_string(),
562        ));
563    }
564    Ok(())
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::ids::NodeId;
571    use crate::oof::PredictionPartition;
572
573    fn block(ids: &[&str], values: &[f64]) -> PredictionBlock {
574        PredictionBlock {
575            prediction_id: None,
576            producer_node: NodeId::new("model:regressor").unwrap(),
577            producer_port: Some("prediction".to_string()),
578            partition: PredictionPartition::Validation,
579            fold_id: None,
580            sample_ids: ids.iter().map(|id| SampleId::new(*id).unwrap()).collect(),
581            values: values.iter().map(|value| vec![*value]).collect(),
582            target_names: vec!["y".to_string()],
583        }
584    }
585
586    fn context(ids: Vec<SampleId>, targets: Vec<String>) -> ConformalCalibrationContext {
587        let mut cohort = ConformalCalibrationCohort {
588            role: "calibration".to_string(),
589            physical_sample_ids: ids.clone(),
590            origin_sample_ids: ids,
591            target_names: targets,
592            manifest_fingerprint: String::new(),
593        };
594        cohort.manifest_fingerprint = cohort.compute_fingerprint().unwrap();
595        let mut context = ConformalCalibrationContext {
596            predictor_binding_fingerprint: "1".repeat(64),
597            source_training_outcome_fingerprint: "2".repeat(64),
598            calibration_replay_outcome_fingerprint: "3".repeat(64),
599            data_identities_fingerprint: "4".repeat(64),
600            fold_set_fingerprint: "5".repeat(64),
601            training_influence_fingerprint: "6".repeat(64),
602            relation_fingerprint: "7".repeat(64),
603            calibration_cohort: cohort,
604            context_fingerprint: String::new(),
605        };
606        context.context_fingerprint = context.compute_fingerprint().unwrap();
607        context
608    }
609
610    #[test]
611    fn calibration_round_trips_and_application_preserves_replay_ids() {
612        let calibration = ConformalCalibration::calibrate_with_truth(
613            "output:main",
614            vec!["y".to_string()],
615            &block(&["s1", "s2", "s3"], &[1.0, 3.0, 5.0]),
616            &ConformalCalibrationTruth {
617                sample_ids: vec![
618                    SampleId::new("s1").unwrap(),
619                    SampleId::new("s2").unwrap(),
620                    SampleId::new("s3").unwrap(),
621                ],
622                values: vec![vec![0.0], vec![2.0], vec![4.0]],
623            },
624            context(
625                vec![
626                    SampleId::new("s1").unwrap(),
627                    SampleId::new("s2").unwrap(),
628                    SampleId::new("s3").unwrap(),
629                ],
630                vec!["y".to_string()],
631            ),
632            vec![0.5],
633            ConformalMultiTargetPolicy::Marginal,
634            ConformalSmallSamplePolicy::Error,
635        )
636        .unwrap();
637        let json = serde_json::to_string(&calibration).unwrap();
638        let loaded = ConformalCalibration::from_json(&json).unwrap();
639        let replay = block(&["new:2", "new:1"], &[10.0, 20.0]);
640        let intervals = loaded.apply(&replay).unwrap();
641        assert_eq!(intervals.sample_ids, replay.sample_ids);
642        assert_eq!(intervals.intervals.len(), 1);
643        let cell = intervals.intervals[0].cells[0][0];
644        assert_eq!(cell.endpoints(), (Some(9.0), Some(11.0)));
645    }
646
647    #[test]
648    fn calibration_preserves_non_binary_coverage_fingerprint() {
649        let calibration = ConformalCalibration::calibrate_with_truth(
650            "output:main",
651            vec!["y".to_string()],
652            &block(&["s1", "s2", "s3", "s4"], &[57.28, 69.52, 82.78, 97.06]),
653            &ConformalCalibrationTruth {
654                sample_ids: vec![
655                    SampleId::new("s1").unwrap(),
656                    SampleId::new("s2").unwrap(),
657                    SampleId::new("s3").unwrap(),
658                    SampleId::new("s4").unwrap(),
659                ],
660                values: vec![vec![64.0], vec![81.0], vec![100.0], vec![121.0]],
661            },
662            context(
663                vec![
664                    SampleId::new("s1").unwrap(),
665                    SampleId::new("s2").unwrap(),
666                    SampleId::new("s3").unwrap(),
667                    SampleId::new("s4").unwrap(),
668                ],
669                vec!["y".to_string()],
670            ),
671            vec![0.8],
672            ConformalMultiTargetPolicy::Marginal,
673            ConformalSmallSamplePolicy::Error,
674        );
675        let calibration = calibration.unwrap();
676        let json = serde_json::to_string(&calibration).unwrap();
677        assert!(ConformalCalibration::from_json(&json).is_ok());
678    }
679
680    #[test]
681    fn calibration_refuses_order_and_tamper() {
682        let prediction = block(&["s1", "s2"], &[1.0, 2.0]);
683        assert!(ConformalCalibration::calibrate_with_truth(
684            "output:main",
685            vec!["y".to_string()],
686            &prediction,
687            &ConformalCalibrationTruth {
688                sample_ids: vec![SampleId::new("s2").unwrap(), SampleId::new("s1").unwrap()],
689                values: vec![vec![1.0], vec![0.0]],
690            },
691            context(prediction.sample_ids.clone(), vec!["y".to_string()]),
692            vec![0.5],
693            ConformalMultiTargetPolicy::Marginal,
694            ConformalSmallSamplePolicy::Error,
695        )
696        .is_err());
697        assert!(ConformalCalibration::calibrate_with_truth(
698            "output:main",
699            vec!["wrong".to_string()],
700            &prediction,
701            &ConformalCalibrationTruth {
702                sample_ids: prediction.sample_ids.clone(),
703                values: vec![vec![0.0], vec![1.0]]
704            },
705            context(prediction.sample_ids.clone(), vec!["wrong".to_string()]),
706            vec![0.5],
707            ConformalMultiTargetPolicy::Marginal,
708            ConformalSmallSamplePolicy::Error
709        )
710        .is_err());
711        let calibration = ConformalCalibration::calibrate_with_truth(
712            "output:main",
713            vec!["y".to_string()],
714            &prediction,
715            &ConformalCalibrationTruth {
716                sample_ids: prediction.sample_ids.clone(),
717                values: vec![vec![0.0], vec![1.0]],
718            },
719            context(prediction.sample_ids.clone(), vec!["y".to_string()]),
720            vec![0.5],
721            ConformalMultiTargetPolicy::Marginal,
722            ConformalSmallSamplePolicy::Error,
723        )
724        .unwrap();
725        let mut value = serde_json::to_value(calibration).unwrap();
726        value["quantiles"][0]["rank"] = serde_json::json!(1);
727        let mut resigned: ConformalCalibration = serde_json::from_value(value.clone()).unwrap();
728        resigned.calibration_fingerprint = resigned.compute_fingerprint().unwrap();
729        value = serde_json::to_value(resigned).unwrap();
730        assert!(ConformalCalibration::from_json(&value.to_string()).is_err());
731    }
732
733    #[test]
734    fn v2_context_is_required_and_interval_bounds_close_over_points() {
735        let prediction = block(&["cal:1", "cal:2"], &[3.0, 7.0]);
736        let truth = ConformalCalibrationTruth {
737            sample_ids: prediction.sample_ids.clone(),
738            values: vec![vec![2.0], vec![5.0]],
739        };
740        let calibration = ConformalCalibration::calibrate_with_truth(
741            "output:main",
742            vec!["y".to_string()],
743            &prediction,
744            &truth,
745            context(prediction.sample_ids.clone(), vec!["y".to_string()]),
746            vec![0.5],
747            ConformalMultiTargetPolicy::Marginal,
748            ConformalSmallSamplePolicy::Error,
749        )
750        .unwrap();
751        let replay = block(&["replay:1"], &[10.0]);
752        let mut intervals = calibration.apply(&replay).unwrap();
753        intervals.validate_against(&calibration, &replay).unwrap();
754        intervals.intervals[0].coverage = 0.8;
755        assert!(intervals.validate_against(&calibration, &replay).is_err());
756
757        let mut v1 = serde_json::to_value(&calibration).unwrap();
758        v1["schema_version"] = serde_json::json!(1);
759        assert!(ConformalCalibration::from_json(&v1.to_string()).is_err());
760        let mut missing_context = serde_json::to_value(&calibration).unwrap();
761        missing_context.as_object_mut().unwrap().remove("context");
762        assert!(ConformalCalibration::from_json(&missing_context.to_string()).is_err());
763    }
764}