1use 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
23pub const CONFORMAL_RUNTIME_SCHEMA_VERSION: u32 = 2;
26
27#[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#[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#[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#[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#[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#[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 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 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}