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;
22use crate::phase::Phase;
23use crate::replay::{TrainingReplayOutcome, TrainingReplayRequest};
24use crate::training::PortablePredictorPackage;
25
26/// V1 did not bind calibration to the training/replay provenance closure.  It
27/// is deliberately not accepted: callers must migrate to this closed V2 form.
28pub const CONFORMAL_RUNTIME_SCHEMA_VERSION: u32 = 2;
29
30/// Stable, presentation-only projection of one validated single-target
31/// conformal PREDICT replay.  It contains no calibration algorithm input and
32/// no mutable model handle: Core and Studio may transport or render it, but
33/// must never recalculate its bounds.
34pub 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    /// `None` represents a deliberately unbounded interval, never an
41    /// infinity/sentinel endpoint.
42    pub lower: Vec<Option<f64>>,
43    pub upper: Vec<Option<f64>>,
44    /// Exact native split-conformal radius for this coverage, or `None` for
45    /// the declared unbounded small-sample policy.
46    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/// Relation-derived calibration cohort. Physical and origin identities are
65/// both retained so a relation-expanded training cohort cannot be bypassed by
66/// presenting only one namespace. The attachment boundary derives and checks
67/// these fields from an authoritative [`crate::relation::SampleRelationSet`].
68#[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/// Complete, canonical provenance closure supplied by the replay boundary.
79/// These are not optional hints: attached calibration checks every member
80/// against the exact source outcome and replay before it is persisted.
81#[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/// Closed, self-fingerprinted split-conformal state retained beside a bundle.
96/// `sample_ids` is the calibration order, not an interchangeable set: this
97/// makes accidental positional joins fail before residuals are calculated.
98#[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/// Typed reference retained by portable execution bundles.  It contains no
114/// host object or duplicate algorithm state; the complete state stays in the
115/// matching `TrainingOutcome`.
116#[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/// Identity-preserving interval result for one replayed point block.
125#[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/// Truth supplied by the data layer for a calibration replay.  It carries the
137/// same stable physical sample ids as the point block so a host can never
138/// smuggle a positional `y_true` matrix across a reordered replay.
139#[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
283/// Project a verified Package V2 and PREDICT replay into the exact scalar
284/// representation consumed by the shared UI.  Multi-target output is refused
285/// instead of selecting or reshaping a target implicitly.
286pub 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        // The kernel validates coverage ordering, radius shape, and nestedness
542        // before application; applying to one finite dummy row is a compact
543        // validation that does not introduce another conformal algorithm.
544        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    /// Validate interval closure against the actual point block and quantiles;
675    /// a matching hash alone is never treated as sufficient.
676    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    // TCV1 fingerprints the lexical binary64 token.  A radius produced by
776    // native arithmetic can need one serde round-trip before that token is the
777    // same one a strict JSON reader will observe.  Sign only that fixed point;
778    // otherwise a newly created calibration can reject its own serialized form.
779    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}