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;
22use crate::phase::Phase;
23use crate::replay::{TrainingReplayOutcome, TrainingReplayRequest};
24use crate::training::PortablePredictorPackage;
25
26pub const CONFORMAL_RUNTIME_SCHEMA_VERSION: u32 = 2;
29
30pub const CONFORMAL_PRESENTATION_SCHEMA_VERSION: u32 = 1;
35
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct ConformalPresentationInterval {
39 pub coverage: f64,
40 pub lower: Vec<Option<f64>>,
43 pub upper: Vec<Option<f64>>,
44 pub qhat: Option<f64>,
47}
48
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct ConformalPresentationV1 {
52 pub schema_version: u32,
53 pub package_fingerprint: String,
54 pub replay_outcome_fingerprint: String,
55 pub binding_id: String,
56 pub target_name: String,
57 pub sample_ids: Vec<SampleId>,
58 pub point_predictions: Vec<f64>,
59 pub intervals: Vec<ConformalPresentationInterval>,
60 pub calibration_fingerprint: String,
61 pub presentation_fingerprint: String,
62}
63
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct ConformalCalibrationCohort {
71 pub role: String,
72 pub physical_sample_ids: Vec<SampleId>,
73 pub origin_sample_ids: Vec<SampleId>,
74 pub target_names: Vec<String>,
75 pub manifest_fingerprint: String,
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct ConformalCalibrationContext {
84 pub predictor_binding_fingerprint: String,
85 pub source_training_outcome_fingerprint: String,
86 pub calibration_replay_outcome_fingerprint: String,
87 pub data_identities_fingerprint: String,
88 pub fold_set_fingerprint: String,
89 pub training_influence_fingerprint: String,
90 pub relation_fingerprint: String,
91 pub calibration_cohort: ConformalCalibrationCohort,
92 pub context_fingerprint: String,
93}
94
95#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct ConformalCalibration {
101 pub schema_version: u32,
102 pub binding_id: String,
103 pub target_names: Vec<String>,
104 pub sample_ids: Vec<SampleId>,
105 pub coverages: Vec<f64>,
106 pub multi_target_policy: ConformalMultiTargetPolicy,
107 pub small_sample_policy: ConformalSmallSamplePolicy,
108 pub quantiles: Vec<SplitConformalQuantile>,
109 pub context: ConformalCalibrationContext,
110 pub calibration_fingerprint: String,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct ConformalCalibrationRef {
119 pub schema_version: u32,
120 pub binding_id: String,
121 pub calibration_fingerprint: String,
122}
123
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ConformalIntervalBlock {
128 pub schema_version: u32,
129 pub binding_id: String,
130 pub sample_ids: Vec<SampleId>,
131 pub intervals: Vec<RegressionConformalInterval>,
132 pub calibration_fingerprint: String,
133 pub point_prediction_fingerprint: String,
134}
135
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct ConformalCalibrationTruth {
142 pub sample_ids: Vec<SampleId>,
143 pub values: Vec<Vec<f64>>,
144}
145
146impl ConformalIntervalBlock {
147 pub fn validate(&self) -> Result<()> {
148 if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
149 || self.binding_id.trim().is_empty()
150 {
151 return Err(DagMlError::RuntimeValidation(
152 "conformal interval block has an unsupported version or empty binding id"
153 .to_string(),
154 ));
155 }
156 validate_unique_samples(&self.sample_ids)?;
157 if self.intervals.is_empty()
158 || self
159 .intervals
160 .iter()
161 .any(|interval| interval.cells.len() != self.sample_ids.len())
162 {
163 return Err(DagMlError::RuntimeValidation(
164 "conformal interval block does not cover its exact sample ids".to_string(),
165 ));
166 }
167 validate_sha256(&self.calibration_fingerprint)?;
168 validate_sha256(&self.point_prediction_fingerprint)
169 }
170}
171
172impl ConformalPresentationV1 {
173 pub fn from_json(json: &str) -> Result<Self> {
174 let raw_fingerprint = parse_typed_json(json)
175 .and_then(|value| value.fingerprint_without("presentation_fingerprint"))
176 .map_err(|error| {
177 DagMlError::RuntimeValidation(format!(
178 "conformal presentation is outside strict TCV1 JSON: {error}"
179 ))
180 })?;
181 let presentation: Self = serde_json::from_str(json)?;
182 if presentation.presentation_fingerprint != raw_fingerprint {
183 return Err(DagMlError::RuntimeValidation(
184 "conformal presentation fingerprint does not match original TCV1 JSON".to_string(),
185 ));
186 }
187 presentation.validate()?;
188 Ok(presentation)
189 }
190
191 pub fn compute_fingerprint(&self) -> Result<String> {
192 let json = serde_json::to_string(self)?;
193 parse_typed_json(&json)
194 .and_then(|value| value.fingerprint_without("presentation_fingerprint"))
195 .map_err(|error| {
196 DagMlError::RuntimeValidation(format!(
197 "conformal presentation is outside strict TCV1 JSON: {error}"
198 ))
199 })
200 }
201
202 pub fn validate(&self) -> Result<()> {
203 if self.schema_version != CONFORMAL_PRESENTATION_SCHEMA_VERSION
204 || self.binding_id.trim().is_empty()
205 || self.target_name.trim().is_empty()
206 {
207 return Err(DagMlError::RuntimeValidation(
208 "conformal presentation has an unsupported version or empty binding metadata"
209 .to_string(),
210 ));
211 }
212 for fingerprint in [
213 &self.package_fingerprint,
214 &self.replay_outcome_fingerprint,
215 &self.calibration_fingerprint,
216 &self.presentation_fingerprint,
217 ] {
218 validate_sha256(fingerprint)?;
219 }
220 validate_unique_samples(&self.sample_ids)?;
221 if self.sample_ids.is_empty()
222 || self.point_predictions.len() != self.sample_ids.len()
223 || self
224 .point_predictions
225 .iter()
226 .any(|value| !value.is_finite())
227 || self.intervals.is_empty()
228 {
229 return Err(DagMlError::RuntimeValidation(
230 "conformal presentation does not exactly cover finite point predictions"
231 .to_string(),
232 ));
233 }
234 let mut prior_coverage = None;
235 for interval in &self.intervals {
236 if !(interval.coverage.is_finite()
237 && 0.0 < interval.coverage
238 && interval.coverage < 1.0)
239 || prior_coverage.is_some_and(|prior| prior >= interval.coverage)
240 || interval.lower.len() != self.sample_ids.len()
241 || interval.upper.len() != self.sample_ids.len()
242 || interval
243 .qhat
244 .is_some_and(|value| !value.is_finite() || value < 0.0)
245 {
246 return Err(DagMlError::RuntimeValidation(
247 "conformal presentation has invalid coverage or interval cardinality"
248 .to_string(),
249 ));
250 }
251 for ((point, lower), upper) in self
252 .point_predictions
253 .iter()
254 .zip(&interval.lower)
255 .zip(&interval.upper)
256 {
257 match (lower, upper) {
258 (Some(lower), Some(upper))
259 if lower.is_finite()
260 && upper.is_finite()
261 && lower <= point
262 && point <= upper => {}
263 (None, None) if interval.qhat.is_none() => {}
264 _ => {
265 return Err(DagMlError::RuntimeValidation(
266 "conformal presentation interval endpoints are inconsistent"
267 .to_string(),
268 ));
269 }
270 }
271 }
272 prior_coverage = Some(interval.coverage);
273 }
274 if self.presentation_fingerprint != self.compute_fingerprint()? {
275 return Err(DagMlError::RuntimeValidation(
276 "conformal presentation fingerprint does not match TCV1 content".to_string(),
277 ));
278 }
279 Ok(())
280 }
281}
282
283pub fn build_conformal_presentation_v1(
287 package: &PortablePredictorPackage,
288 request: &TrainingReplayRequest,
289 replay: &TrainingReplayOutcome,
290) -> Result<ConformalPresentationV1> {
291 package.validate()?;
292 request.validate()?;
293 replay.validate_against_package(package, request)?;
294 if replay.phase != Phase::Predict {
295 return Err(DagMlError::RuntimeValidation(
296 "conformal presentation requires a PREDICT replay".to_string(),
297 ));
298 }
299 let calibration = package.conformal_calibration.as_ref().ok_or_else(|| {
300 DagMlError::RuntimeValidation(
301 "conformal presentation requires package calibration state".to_string(),
302 )
303 })?;
304 if calibration.target_names.len() != 1 {
305 return Err(DagMlError::RuntimeValidation(
306 "conformal presentation refuses multi-target output".to_string(),
307 ));
308 }
309 let output = replay
310 .outputs
311 .iter()
312 .find(|output| output.binding.binding_id == calibration.binding_id)
313 .ok_or_else(|| {
314 DagMlError::RuntimeValidation(
315 "conformal presentation replay is missing the calibrated binding".to_string(),
316 )
317 })?;
318 if output.binding.target_names != calibration.target_names || output.predictions.len() != 1 {
319 return Err(DagMlError::RuntimeValidation(
320 "conformal presentation requires exactly one matching scalar point block".to_string(),
321 ));
322 }
323 let point = &output.predictions[0];
324 point.validate_content()?;
325 if point
326 .values
327 .iter()
328 .any(|row| row.len() != 1 || !row[0].is_finite())
329 {
330 return Err(DagMlError::RuntimeValidation(
331 "conformal presentation requires finite single-target point predictions".to_string(),
332 ));
333 }
334 let intervals = replay
335 .conformal_intervals
336 .iter()
337 .filter(|interval| interval.binding_id == calibration.binding_id)
338 .collect::<Vec<_>>();
339 if intervals.len() != 1 {
340 return Err(DagMlError::RuntimeValidation(
341 "conformal presentation requires exactly one calibrated interval block".to_string(),
342 ));
343 }
344 let interval_block = intervals[0];
345 interval_block.validate_against(calibration, point)?;
346 let mut presentation_intervals = Vec::with_capacity(interval_block.intervals.len());
347 for interval in &interval_block.intervals {
348 let quantile = calibration
349 .quantiles
350 .iter()
351 .find(|quantile| quantile.coverage == interval.coverage)
352 .ok_or_else(|| {
353 DagMlError::RuntimeValidation(
354 "conformal presentation interval coverage is absent from calibration"
355 .to_string(),
356 )
357 })?;
358 if quantile.radii.len() != 1 || interval.cells.iter().any(|row| row.len() != 1) {
359 return Err(DagMlError::RuntimeValidation(
360 "conformal presentation requires scalar calibration radii and cells".to_string(),
361 ));
362 }
363 let qhat = match quantile.radii[0] {
364 crate::conformal::ConformalRadius::Finite(value)
365 if value.is_finite() && value >= 0.0 =>
366 {
367 Some(value)
368 }
369 crate::conformal::ConformalRadius::Unbounded => None,
370 _ => {
371 return Err(DagMlError::RuntimeValidation(
372 "conformal presentation calibration radius is invalid".to_string(),
373 ));
374 }
375 };
376 let (lower, upper) = interval.cells.iter().map(|row| row[0].endpoints()).unzip();
377 presentation_intervals.push(ConformalPresentationInterval {
378 coverage: interval.coverage,
379 lower,
380 upper,
381 qhat,
382 });
383 }
384 presentation_intervals.sort_by(|left, right| left.coverage.total_cmp(&right.coverage));
385 let mut presentation = ConformalPresentationV1 {
386 schema_version: CONFORMAL_PRESENTATION_SCHEMA_VERSION,
387 package_fingerprint: package.package_fingerprint.clone(),
388 replay_outcome_fingerprint: replay.outcome_fingerprint.clone(),
389 binding_id: calibration.binding_id.clone(),
390 target_name: calibration.target_names[0].clone(),
391 sample_ids: point.sample_ids.clone(),
392 point_predictions: point.values.iter().map(|row| row[0]).collect(),
393 intervals: presentation_intervals,
394 calibration_fingerprint: calibration.calibration_fingerprint.clone(),
395 presentation_fingerprint: "0".repeat(64),
396 };
397 presentation.presentation_fingerprint = presentation.compute_fingerprint()?;
398 presentation.validate()?;
399 Ok(presentation)
400}
401
402impl ConformalCalibration {
403 #[allow(clippy::too_many_arguments)]
404 pub fn calibrate_with_truth(
405 binding_id: impl Into<String>,
406 target_names: Vec<String>,
407 predictions: &PredictionBlock,
408 truth: &ConformalCalibrationTruth,
409 context: ConformalCalibrationContext,
410 coverages: Vec<f64>,
411 multi_target_policy: ConformalMultiTargetPolicy,
412 small_sample_policy: ConformalSmallSamplePolicy,
413 ) -> Result<Self> {
414 predictions.validate_content()?;
415 validate_identity_aligned_truth(predictions, truth)?;
416 context.validate_for_truth(truth, &target_names)?;
417 if target_names.len() != predictions.values[0].len()
418 || (!predictions.target_names.is_empty() && predictions.target_names != target_names)
419 {
420 return Err(DagMlError::RuntimeValidation(
421 "conformal target order does not match the point prediction binding".to_string(),
422 ));
423 }
424 let residuals = predictions
425 .values
426 .iter()
427 .zip(&truth.values)
428 .map(|(prediction, actual)| {
429 prediction
430 .iter()
431 .zip(actual)
432 .map(|(point, value)| (point - value).abs())
433 .collect::<Vec<_>>()
434 })
435 .collect::<Vec<_>>();
436 let quantiles = split_absolute_residual_quantiles(
437 &residuals,
438 &coverages,
439 multi_target_policy,
440 small_sample_policy,
441 )
442 .map_err(|error| {
443 DagMlError::RuntimeValidation(format!("conformal calibration failed: {error}"))
444 })?;
445 let calibration = Self {
446 schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
447 binding_id: binding_id.into(),
448 target_names,
449 sample_ids: predictions.sample_ids.clone(),
450 coverages,
451 multi_target_policy,
452 small_sample_policy,
453 quantiles,
454 context,
455 calibration_fingerprint: String::new(),
456 };
457 stabilize_calibration_for_tcv1(calibration)
458 }
459
460 pub fn reference(&self) -> Result<ConformalCalibrationRef> {
461 self.validate()?;
462 Ok(ConformalCalibrationRef {
463 schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
464 binding_id: self.binding_id.clone(),
465 calibration_fingerprint: self.calibration_fingerprint.clone(),
466 })
467 }
468
469 pub fn compute_fingerprint(&self) -> Result<String> {
470 fingerprint_without(self, "calibration_fingerprint", "conformal calibration")
471 }
472
473 pub fn from_json(json: &str) -> Result<Self> {
474 let raw = parse_typed_json(json)
475 .and_then(|value| value.fingerprint_without("calibration_fingerprint"))
476 .map_err(|error| {
477 DagMlError::RuntimeValidation(format!(
478 "conformal calibration is not strict TCV1 JSON: {error}"
479 ))
480 })?;
481 let calibration: Self = serde_json::from_str(json)?;
482 if calibration.calibration_fingerprint != raw {
483 return Err(DagMlError::RuntimeValidation(
484 "conformal calibration fingerprint does not match original TCV1 JSON".to_string(),
485 ));
486 }
487 calibration.validate()?;
488 Ok(calibration)
489 }
490
491 pub fn validate(&self) -> Result<()> {
492 if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION {
493 return Err(DagMlError::RuntimeValidation(format!(
494 "conformal calibration has unsupported schema_version {}",
495 self.schema_version
496 )));
497 }
498 if self.binding_id.trim().is_empty() || self.target_names.is_empty() {
499 return Err(DagMlError::RuntimeValidation(
500 "conformal calibration requires a binding id and target names".to_string(),
501 ));
502 }
503 validate_unique_samples(&self.sample_ids)?;
504 self.context.validate_for_calibration(self)?;
505 if self.coverages.is_empty() || self.quantiles.len() != self.coverages.len() {
506 return Err(DagMlError::RuntimeValidation(
507 "conformal calibration coverages and quantiles must have equal non-zero length"
508 .to_string(),
509 ));
510 }
511 if self
512 .quantiles
513 .iter()
514 .zip(&self.coverages)
515 .any(|(quantile, coverage)| quantile.coverage.to_bits() != coverage.to_bits())
516 {
517 return Err(DagMlError::RuntimeValidation(
518 "conformal calibration quantile coverage order does not match coverages"
519 .to_string(),
520 ));
521 }
522 let sample_count = u64::try_from(self.sample_ids.len()).map_err(|_| {
523 DagMlError::RuntimeValidation(
524 "conformal calibration sample count exceeds u64".to_string(),
525 )
526 })?;
527 for (index, (coverage, quantile)) in self.coverages.iter().zip(&self.quantiles).enumerate()
528 {
529 let expected =
530 finite_sample_conformal_rank(sample_count, *coverage).map_err(|error| {
531 DagMlError::RuntimeValidation(format!(
532 "invalid conformal rank at coverage {index}: {error}"
533 ))
534 })?;
535 if quantile.rank != expected {
536 return Err(DagMlError::RuntimeValidation(format!(
537 "conformal quantile rank at coverage {index} does not match sample count and coverage"
538 )));
539 }
540 }
541 apply_split_absolute_residual(
545 &[vec![0.0; self.target_names.len()]],
546 &self.quantiles,
547 self.multi_target_policy,
548 )
549 .map_err(|error| {
550 DagMlError::RuntimeValidation(format!("invalid conformal quantiles: {error}"))
551 })?;
552 validate_sha256(&self.calibration_fingerprint)?;
553 if self.calibration_fingerprint != self.compute_fingerprint()? {
554 return Err(DagMlError::RuntimeValidation(
555 "conformal calibration fingerprint does not match TCV1 content".to_string(),
556 ));
557 }
558 Ok(())
559 }
560
561 pub fn apply(&self, predictions: &PredictionBlock) -> Result<ConformalIntervalBlock> {
562 self.validate()?;
563 predictions.validate_content()?;
564 if predictions.target_names != self.target_names {
565 return Err(DagMlError::RuntimeValidation(
566 "conformal application target order does not match calibration".to_string(),
567 ));
568 }
569 let intervals = apply_split_absolute_residual(
570 &predictions.values,
571 &self.quantiles,
572 self.multi_target_policy,
573 )
574 .map_err(|error| {
575 DagMlError::RuntimeValidation(format!("conformal application failed: {error}"))
576 })?;
577 Ok(ConformalIntervalBlock {
578 schema_version: CONFORMAL_RUNTIME_SCHEMA_VERSION,
579 binding_id: self.binding_id.clone(),
580 sample_ids: predictions.sample_ids.clone(),
581 intervals,
582 calibration_fingerprint: self.calibration_fingerprint.clone(),
583 point_prediction_fingerprint: point_prediction_fingerprint_for_runtime(predictions)?,
584 })
585 }
586}
587
588impl ConformalCalibrationContext {
589 pub fn compute_fingerprint(&self) -> Result<String> {
590 fingerprint_without(self, "context_fingerprint", "conformal calibration context")
591 }
592
593 pub fn validate_for_truth(
594 &self,
595 truth: &ConformalCalibrationTruth,
596 target_names: &[String],
597 ) -> Result<()> {
598 self.validate()?;
599 if self.calibration_cohort.physical_sample_ids != truth.sample_ids
600 || self.calibration_cohort.target_names != target_names
601 {
602 return Err(DagMlError::RuntimeValidation(
603 "conformal calibration cohort must exactly bind truth sample ids and targets"
604 .to_string(),
605 ));
606 }
607 Ok(())
608 }
609
610 pub fn validate(&self) -> Result<()> {
611 for value in [
612 &self.predictor_binding_fingerprint,
613 &self.source_training_outcome_fingerprint,
614 &self.calibration_replay_outcome_fingerprint,
615 &self.data_identities_fingerprint,
616 &self.fold_set_fingerprint,
617 &self.training_influence_fingerprint,
618 &self.relation_fingerprint,
619 &self.context_fingerprint,
620 ] {
621 validate_sha256(value)?;
622 }
623 self.calibration_cohort.validate()?;
624 if self.context_fingerprint != self.compute_fingerprint()? {
625 return Err(DagMlError::RuntimeValidation(
626 "conformal calibration context fingerprint does not match TCV1 content".to_string(),
627 ));
628 }
629 Ok(())
630 }
631
632 fn validate_for_calibration(&self, calibration: &ConformalCalibration) -> Result<()> {
633 self.validate_for_truth(
634 &ConformalCalibrationTruth {
635 sample_ids: calibration.sample_ids.clone(),
636 values: vec![vec![0.0]; calibration.sample_ids.len()],
637 },
638 &calibration.target_names,
639 )
640 }
641}
642
643impl ConformalCalibrationCohort {
644 pub fn compute_fingerprint(&self) -> Result<String> {
645 fingerprint_without(self, "manifest_fingerprint", "conformal calibration cohort")
646 }
647
648 pub fn validate(&self) -> Result<()> {
649 validate_sha256(&self.manifest_fingerprint)?;
650 if self.role != "calibration" || self.target_names.is_empty() {
651 return Err(DagMlError::RuntimeValidation(
652 "conformal calibration context requires calibration cohort role and targets"
653 .to_string(),
654 ));
655 }
656 validate_unique_samples(&self.physical_sample_ids)?;
657 if self.origin_sample_ids.iter().collect::<BTreeSet<_>>().len()
658 != self.origin_sample_ids.len()
659 {
660 return Err(DagMlError::RuntimeValidation(
661 "conformal calibration origin sample ids must be unique".to_string(),
662 ));
663 }
664 if self.manifest_fingerprint != self.compute_fingerprint()? {
665 return Err(DagMlError::RuntimeValidation(
666 "conformal calibration cohort fingerprint does not match TCV1 content".to_string(),
667 ));
668 }
669 Ok(())
670 }
671}
672
673impl ConformalIntervalBlock {
674 pub fn validate_against(
677 &self,
678 calibration: &ConformalCalibration,
679 predictions: &PredictionBlock,
680 ) -> Result<()> {
681 self.validate()?;
682 calibration.validate()?;
683 if self.binding_id != calibration.binding_id
684 || self.calibration_fingerprint != calibration.calibration_fingerprint
685 || self.sample_ids != predictions.sample_ids
686 || self.point_prediction_fingerprint
687 != point_prediction_fingerprint_for_runtime(predictions)?
688 {
689 return Err(DagMlError::RuntimeValidation("conformal interval block is not bound to its calibration and point prediction block".to_string()));
690 }
691 let expected = calibration.apply(predictions)?;
692 if self != &expected {
693 return Err(DagMlError::RuntimeValidation(
694 "conformal interval bounds do not close over point predictions and quantiles"
695 .to_string(),
696 ));
697 }
698 Ok(())
699 }
700}
701
702impl ConformalCalibrationRef {
703 pub fn validate(&self) -> Result<()> {
704 if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
705 || self.binding_id.trim().is_empty()
706 {
707 return Err(DagMlError::RuntimeValidation(
708 "conformal calibration reference has an unsupported version or empty binding id"
709 .to_string(),
710 ));
711 }
712 validate_sha256(&self.calibration_fingerprint)
713 }
714
715 pub fn validate_against(&self, calibration: &ConformalCalibration) -> Result<()> {
716 self.validate()?;
717 calibration.validate()?;
718 if self.schema_version != CONFORMAL_RUNTIME_SCHEMA_VERSION
719 || self.binding_id != calibration.binding_id
720 || self.calibration_fingerprint != calibration.calibration_fingerprint
721 {
722 return Err(DagMlError::RuntimeValidation(
723 "conformal calibration reference does not match calibration state".to_string(),
724 ));
725 }
726 Ok(())
727 }
728}
729
730fn validate_identity_aligned_truth(
731 predictions: &PredictionBlock,
732 truth: &ConformalCalibrationTruth,
733) -> Result<()> {
734 if predictions.sample_ids != truth.sample_ids
735 || predictions.values.len() != truth.values.len()
736 || truth.values.is_empty()
737 || truth
738 .values
739 .iter()
740 .any(|row| row.len() != predictions.values[0].len())
741 || truth
742 .values
743 .iter()
744 .flatten()
745 .any(|value| !value.is_finite())
746 {
747 return Err(DagMlError::RuntimeValidation(
748 "conformal truth must be finite and exactly row/target aligned by sample id"
749 .to_string(),
750 ));
751 }
752 Ok(())
753}
754
755fn validate_unique_samples(sample_ids: &[SampleId]) -> Result<()> {
756 if sample_ids.is_empty() || sample_ids.iter().collect::<BTreeSet<_>>().len() != sample_ids.len()
757 {
758 return Err(DagMlError::RuntimeValidation(
759 "conformal calibration requires non-empty unique sample ids".to_string(),
760 ));
761 }
762 Ok(())
763}
764
765fn fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
766 let json = serde_json::to_string(value)?;
767 parse_typed_json(&json)
768 .and_then(|typed| typed.fingerprint_without(field))
769 .map_err(|error| DagMlError::RuntimeValidation(format!("{label} is outside TCV1: {error}")))
770}
771
772fn stabilize_calibration_for_tcv1(
773 mut calibration: ConformalCalibration,
774) -> Result<ConformalCalibration> {
775 calibration.calibration_fingerprint = "0".repeat(64);
780 for _ in 0..8 {
781 let json = serde_json::to_string(&calibration)?;
782 let before = parse_typed_json(&json).map_err(|error| {
783 DagMlError::RuntimeValidation(format!(
784 "conformal calibration is outside TCV1 while normalizing: {error}"
785 ))
786 })?;
787 let mut normalized = serde_json::from_str::<ConformalCalibration>(&json)?;
788 normalized.calibration_fingerprint = "0".repeat(64);
789 let normalized_json = serde_json::to_string(&normalized)?;
790 let after = parse_typed_json(&normalized_json).map_err(|error| {
791 DagMlError::RuntimeValidation(format!(
792 "conformal calibration is outside TCV1 after normalization: {error}"
793 ))
794 })?;
795 if before != after {
796 calibration = normalized;
797 continue;
798 }
799 normalized.calibration_fingerprint = after
800 .fingerprint_without("calibration_fingerprint")
801 .map_err(|error| {
802 DagMlError::RuntimeValidation(format!(
803 "conformal calibration TCV1 fingerprint failed after normalization: {error}"
804 ))
805 })?;
806 let signed_json = serde_json::to_string(&normalized)?;
807 return ConformalCalibration::from_json(&signed_json);
808 }
809 Err(DagMlError::RuntimeValidation(
810 "conformal calibration TCV1 JSON did not reach a serde canonical fixed point".to_string(),
811 ))
812}
813
814pub(crate) fn point_prediction_fingerprint_for_runtime(
815 predictions: &PredictionBlock,
816) -> Result<String> {
817 predictions.validate_content()?;
818 fingerprint_without(predictions, "prediction_id", "conformal point prediction")
819}
820
821fn validate_sha256(value: &str) -> Result<()> {
822 if value.len() != 64
823 || !value
824 .bytes()
825 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
826 {
827 return Err(DagMlError::RuntimeValidation(
828 "conformal calibration fingerprint must be lowercase SHA-256".to_string(),
829 ));
830 }
831 Ok(())
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837 use crate::ids::NodeId;
838 use crate::oof::PredictionPartition;
839
840 fn block(ids: &[&str], values: &[f64]) -> PredictionBlock {
841 PredictionBlock {
842 prediction_id: None,
843 producer_node: NodeId::new("model:regressor").unwrap(),
844 producer_port: Some("prediction".to_string()),
845 partition: PredictionPartition::Validation,
846 fold_id: None,
847 sample_ids: ids.iter().map(|id| SampleId::new(*id).unwrap()).collect(),
848 values: values.iter().map(|value| vec![*value]).collect(),
849 target_names: vec!["y".to_string()],
850 }
851 }
852
853 fn context(ids: Vec<SampleId>, targets: Vec<String>) -> ConformalCalibrationContext {
854 let mut cohort = ConformalCalibrationCohort {
855 role: "calibration".to_string(),
856 physical_sample_ids: ids.clone(),
857 origin_sample_ids: ids,
858 target_names: targets,
859 manifest_fingerprint: String::new(),
860 };
861 cohort.manifest_fingerprint = cohort.compute_fingerprint().unwrap();
862 let mut context = ConformalCalibrationContext {
863 predictor_binding_fingerprint: "1".repeat(64),
864 source_training_outcome_fingerprint: "2".repeat(64),
865 calibration_replay_outcome_fingerprint: "3".repeat(64),
866 data_identities_fingerprint: "4".repeat(64),
867 fold_set_fingerprint: "5".repeat(64),
868 training_influence_fingerprint: "6".repeat(64),
869 relation_fingerprint: "7".repeat(64),
870 calibration_cohort: cohort,
871 context_fingerprint: String::new(),
872 };
873 context.context_fingerprint = context.compute_fingerprint().unwrap();
874 context
875 }
876
877 #[test]
878 fn calibration_round_trips_and_application_preserves_replay_ids() {
879 let calibration = ConformalCalibration::calibrate_with_truth(
880 "output:main",
881 vec!["y".to_string()],
882 &block(&["s1", "s2", "s3"], &[1.0, 3.0, 5.0]),
883 &ConformalCalibrationTruth {
884 sample_ids: vec![
885 SampleId::new("s1").unwrap(),
886 SampleId::new("s2").unwrap(),
887 SampleId::new("s3").unwrap(),
888 ],
889 values: vec![vec![0.0], vec![2.0], vec![4.0]],
890 },
891 context(
892 vec![
893 SampleId::new("s1").unwrap(),
894 SampleId::new("s2").unwrap(),
895 SampleId::new("s3").unwrap(),
896 ],
897 vec!["y".to_string()],
898 ),
899 vec![0.5],
900 ConformalMultiTargetPolicy::Marginal,
901 ConformalSmallSamplePolicy::Error,
902 )
903 .unwrap();
904 let json = serde_json::to_string(&calibration).unwrap();
905 let loaded = ConformalCalibration::from_json(&json).unwrap();
906 let replay = block(&["new:2", "new:1"], &[10.0, 20.0]);
907 let intervals = loaded.apply(&replay).unwrap();
908 assert_eq!(intervals.sample_ids, replay.sample_ids);
909 assert_eq!(intervals.intervals.len(), 1);
910 let cell = intervals.intervals[0].cells[0][0];
911 assert_eq!(cell.endpoints(), (Some(9.0), Some(11.0)));
912 }
913
914 #[test]
915 fn calibration_preserves_non_binary_coverage_fingerprint() {
916 let calibration = ConformalCalibration::calibrate_with_truth(
917 "output:main",
918 vec!["y".to_string()],
919 &block(&["s1", "s2", "s3", "s4"], &[57.28, 69.52, 82.78, 97.06]),
920 &ConformalCalibrationTruth {
921 sample_ids: vec![
922 SampleId::new("s1").unwrap(),
923 SampleId::new("s2").unwrap(),
924 SampleId::new("s3").unwrap(),
925 SampleId::new("s4").unwrap(),
926 ],
927 values: vec![vec![64.0], vec![81.0], vec![100.0], vec![121.0]],
928 },
929 context(
930 vec![
931 SampleId::new("s1").unwrap(),
932 SampleId::new("s2").unwrap(),
933 SampleId::new("s3").unwrap(),
934 SampleId::new("s4").unwrap(),
935 ],
936 vec!["y".to_string()],
937 ),
938 vec![0.8],
939 ConformalMultiTargetPolicy::Marginal,
940 ConformalSmallSamplePolicy::Error,
941 );
942 let calibration = calibration.unwrap();
943 let json = serde_json::to_string(&calibration).unwrap();
944 assert!(ConformalCalibration::from_json(&json).is_ok());
945 }
946
947 #[test]
948 fn calibration_refuses_order_and_tamper() {
949 let prediction = block(&["s1", "s2"], &[1.0, 2.0]);
950 assert!(ConformalCalibration::calibrate_with_truth(
951 "output:main",
952 vec!["y".to_string()],
953 &prediction,
954 &ConformalCalibrationTruth {
955 sample_ids: vec![SampleId::new("s2").unwrap(), SampleId::new("s1").unwrap()],
956 values: vec![vec![1.0], vec![0.0]],
957 },
958 context(prediction.sample_ids.clone(), vec!["y".to_string()]),
959 vec![0.5],
960 ConformalMultiTargetPolicy::Marginal,
961 ConformalSmallSamplePolicy::Error,
962 )
963 .is_err());
964 assert!(ConformalCalibration::calibrate_with_truth(
965 "output:main",
966 vec!["wrong".to_string()],
967 &prediction,
968 &ConformalCalibrationTruth {
969 sample_ids: prediction.sample_ids.clone(),
970 values: vec![vec![0.0], vec![1.0]]
971 },
972 context(prediction.sample_ids.clone(), vec!["wrong".to_string()]),
973 vec![0.5],
974 ConformalMultiTargetPolicy::Marginal,
975 ConformalSmallSamplePolicy::Error
976 )
977 .is_err());
978 let calibration = ConformalCalibration::calibrate_with_truth(
979 "output:main",
980 vec!["y".to_string()],
981 &prediction,
982 &ConformalCalibrationTruth {
983 sample_ids: prediction.sample_ids.clone(),
984 values: vec![vec![0.0], vec![1.0]],
985 },
986 context(prediction.sample_ids.clone(), vec!["y".to_string()]),
987 vec![0.5],
988 ConformalMultiTargetPolicy::Marginal,
989 ConformalSmallSamplePolicy::Error,
990 )
991 .unwrap();
992 let mut value = serde_json::to_value(calibration).unwrap();
993 value["quantiles"][0]["rank"] = serde_json::json!(1);
994 let mut resigned: ConformalCalibration = serde_json::from_value(value.clone()).unwrap();
995 resigned.calibration_fingerprint = resigned.compute_fingerprint().unwrap();
996 value = serde_json::to_value(resigned).unwrap();
997 assert!(ConformalCalibration::from_json(&value.to_string()).is_err());
998 }
999
1000 #[test]
1001 fn v2_context_is_required_and_interval_bounds_close_over_points() {
1002 let prediction = block(&["cal:1", "cal:2"], &[3.0, 7.0]);
1003 let truth = ConformalCalibrationTruth {
1004 sample_ids: prediction.sample_ids.clone(),
1005 values: vec![vec![2.0], vec![5.0]],
1006 };
1007 let calibration = ConformalCalibration::calibrate_with_truth(
1008 "output:main",
1009 vec!["y".to_string()],
1010 &prediction,
1011 &truth,
1012 context(prediction.sample_ids.clone(), vec!["y".to_string()]),
1013 vec![0.5],
1014 ConformalMultiTargetPolicy::Marginal,
1015 ConformalSmallSamplePolicy::Error,
1016 )
1017 .unwrap();
1018 let replay = block(&["replay:1"], &[10.0]);
1019 let mut intervals = calibration.apply(&replay).unwrap();
1020 intervals.validate_against(&calibration, &replay).unwrap();
1021 intervals.intervals[0].coverage = 0.8;
1022 assert!(intervals.validate_against(&calibration, &replay).is_err());
1023
1024 let mut v1 = serde_json::to_value(&calibration).unwrap();
1025 v1["schema_version"] = serde_json::json!(1);
1026 assert!(ConformalCalibration::from_json(&v1.to_string()).is_err());
1027 let mut missing_context = serde_json::to_value(&calibration).unwrap();
1028 missing_context.as_object_mut().unwrap().remove("context");
1029 assert!(ConformalCalibration::from_json(&missing_context.to_string()).is_err());
1030 }
1031
1032 #[test]
1033 fn presentation_round_trips_and_refuses_resigned_interval_tampering() {
1034 let mut presentation = ConformalPresentationV1 {
1035 schema_version: CONFORMAL_PRESENTATION_SCHEMA_VERSION,
1036 package_fingerprint: "1".repeat(64),
1037 replay_outcome_fingerprint: "2".repeat(64),
1038 binding_id: "output:main".to_string(),
1039 target_name: "y".to_string(),
1040 sample_ids: vec![SampleId::new("predict:1").unwrap()],
1041 point_predictions: vec![10.0],
1042 intervals: vec![ConformalPresentationInterval {
1043 coverage: 0.8,
1044 lower: vec![Some(8.0)],
1045 upper: vec![Some(12.0)],
1046 qhat: Some(2.0),
1047 }],
1048 calibration_fingerprint: "3".repeat(64),
1049 presentation_fingerprint: "0".repeat(64),
1050 };
1051 presentation.presentation_fingerprint = presentation.compute_fingerprint().unwrap();
1052 let json = serde_json::to_string(&presentation).unwrap();
1053 assert_eq!(
1054 ConformalPresentationV1::from_json(&json).unwrap(),
1055 presentation
1056 );
1057
1058 let mut tampered: ConformalPresentationV1 = serde_json::from_str(&json).unwrap();
1059 tampered.intervals[0].lower[0] = Some(11.0);
1060 tampered.presentation_fingerprint = tampered.compute_fingerprint().unwrap();
1061 assert!(
1062 ConformalPresentationV1::from_json(&serde_json::to_string(&tampered).unwrap()).is_err()
1063 );
1064 }
1065}