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 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 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 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 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}