Skip to main content

uqa_scoring/
vector_calibration.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Persistable vector-calibration models and their compatibility contract.
8//!
9//! A bare [`crate::VectorProbabilityTransform`] is only a numeric mapping.
10//! Reusing one safely requires knowing the corpus, physical index, embedding
11//! model, candidate-pool size, and versions it was fitted against. This module
12//! keeps that provenance inseparable from a reusable model and rejects a
13//! runtime target that does not match it exactly.
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{invalid_input, require_finite};
18use crate::{ScoringError, ScoringResult, VectorProbabilityTransform};
19
20/// JSON schema version for [`VectorCalibrationModel`].
21pub const VECTOR_CALIBRATION_MODEL_SCHEMA_VERSION: u32 = 1;
22
23/// Runtime identity of the retrieval surface to which a calibration model
24/// applies. Versions are opaque, caller-controlled identifiers (for example a
25/// content digest, catalog generation, or immutable release id).
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct VectorCalibrationTarget {
28    pub corpus_id: String,
29    pub corpus_version: String,
30    pub index_id: String,
31    pub index_version: String,
32    pub index_kind: String,
33    pub embedding_model_id: String,
34    pub embedding_model_version: String,
35    pub candidate_k: usize,
36    pub dimensions: u32,
37}
38
39impl VectorCalibrationTarget {
40    pub fn validate(&self) -> ScoringResult<()> {
41        for (name, value) in [
42            ("corpus_id", self.corpus_id.as_str()),
43            ("corpus_version", self.corpus_version.as_str()),
44            ("index_id", self.index_id.as_str()),
45            ("index_version", self.index_version.as_str()),
46            ("index_kind", self.index_kind.as_str()),
47            ("embedding_model_id", self.embedding_model_id.as_str()),
48            (
49                "embedding_model_version",
50                self.embedding_model_version.as_str(),
51            ),
52        ] {
53            if value.trim().is_empty() {
54                return Err(invalid_input(format!("{name} must not be empty")));
55            }
56        }
57        if self.candidate_k == 0 {
58            return Err(invalid_input("candidate_k must be greater than zero"));
59        }
60        if self.dimensions == 0 {
61            return Err(invalid_input("dimensions must be greater than zero"));
62        }
63        Ok(())
64    }
65}
66
67/// Provenance stored with a fitted vector-calibration model.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct VectorCalibrationProvenance {
70    /// Version of the fitted parameters or training recipe.
71    pub model_version: String,
72    /// Exact retrieval target used for fitting.
73    pub target: VectorCalibrationTarget,
74    /// Number of labeled or background samples used to fit the model.
75    pub fit_sample_count: usize,
76}
77
78impl VectorCalibrationProvenance {
79    pub fn validate(&self) -> ScoringResult<()> {
80        if self.model_version.trim().is_empty() {
81            return Err(invalid_input("model_version must not be empty"));
82        }
83        if self.fit_sample_count < 2 {
84            return Err(invalid_input(format!(
85                "fit_sample_count must be at least 2, got {}",
86                self.fit_sample_count
87            )));
88        }
89        self.target.validate()
90    }
91}
92
93/// A reusable vector-calibration transform with mandatory provenance.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct VectorCalibrationModel {
96    schema_version: u32,
97    transform: VectorProbabilityTransform,
98    provenance: VectorCalibrationProvenance,
99}
100
101impl VectorCalibrationModel {
102    pub fn new(
103        transform: VectorProbabilityTransform,
104        provenance: VectorCalibrationProvenance,
105    ) -> ScoringResult<Self> {
106        let model = Self {
107            schema_version: VECTOR_CALIBRATION_MODEL_SCHEMA_VERSION,
108            transform,
109            provenance,
110        };
111        model.validate()?;
112        Ok(model)
113    }
114
115    pub fn transform(&self) -> VectorProbabilityTransform {
116        self.transform
117    }
118
119    pub fn provenance(&self) -> &VectorCalibrationProvenance {
120        &self.provenance
121    }
122
123    pub fn validate_for(&self, target: &VectorCalibrationTarget) -> ScoringResult<()> {
124        self.validate()?;
125        target.validate()?;
126        if self.provenance.target != *target {
127            return Err(invalid_input(format!(
128                "vector calibration target mismatch: model={:?}, runtime={target:?}",
129                self.provenance.target
130            )));
131        }
132        Ok(())
133    }
134
135    pub fn calibrate_one(
136        &self,
137        distance: f64,
138        target: &VectorCalibrationTarget,
139    ) -> ScoringResult<f64> {
140        self.validate_for(target)?;
141        self.transform.calibrate_one(distance)
142    }
143
144    pub fn calibrate(
145        &self,
146        distances: &[f64],
147        target: &VectorCalibrationTarget,
148    ) -> ScoringResult<Vec<f64>> {
149        self.validate_for(target)?;
150        self.transform.calibrate(distances, None)
151    }
152
153    pub fn to_json(&self) -> ScoringResult<String> {
154        self.validate()?;
155        serde_json::to_string(self)
156            .map_err(|error| invalid_input(format!("serialize vector calibration model: {error}")))
157    }
158
159    pub fn from_json(json: &str) -> ScoringResult<Self> {
160        let model: Self = serde_json::from_str(json).map_err(|error| {
161            invalid_input(format!("deserialize vector calibration model: {error}"))
162        })?;
163        model.validate()?;
164        Ok(model)
165    }
166
167    fn validate(&self) -> ScoringResult<()> {
168        if self.schema_version != VECTOR_CALIBRATION_MODEL_SCHEMA_VERSION {
169            return Err(invalid_input(format!(
170                "unsupported vector calibration schema version {}, expected {}",
171                self.schema_version, VECTOR_CALIBRATION_MODEL_SCHEMA_VERSION
172            )));
173        }
174        VectorProbabilityTransform::new(
175            self.transform.mu_match,
176            self.transform.mu_random,
177            self.transform.sigma,
178            self.transform.base_rate,
179        )?;
180        self.provenance.validate()
181    }
182}
183
184/// Probability drift observed when two calibration models score the same
185/// distance probes. This makes candidate-`K` sensitivity a measured contract
186/// instead of an informal observation.
187#[derive(Debug, Clone, Copy, PartialEq)]
188pub struct VectorCalibrationStabilityReport {
189    pub reference_k: usize,
190    pub candidate_k: usize,
191    pub probe_count: usize,
192    pub mean_absolute_drift: f64,
193    pub max_absolute_drift: f64,
194}
195
196impl VectorCalibrationStabilityReport {
197    pub fn compare(
198        reference: &VectorCalibrationModel,
199        candidate: &VectorCalibrationModel,
200        probe_distances: &[f64],
201    ) -> ScoringResult<Self> {
202        if probe_distances.is_empty() {
203            return Err(invalid_input(
204                "calibration stability probes must not be empty",
205            ));
206        }
207        let reference_target = &reference.provenance.target;
208        let candidate_target = &candidate.provenance.target;
209        let reference_probabilities = reference.calibrate(probe_distances, reference_target)?;
210        let candidate_probabilities = candidate.calibrate(probe_distances, candidate_target)?;
211        let mut sum = 0.0;
212        let mut max = 0.0_f64;
213        for (left, right) in reference_probabilities
214            .iter()
215            .zip(candidate_probabilities.iter())
216        {
217            let drift = (left - right).abs();
218            require_finite(drift, "vector calibration probability drift")?;
219            sum += drift;
220            max = max.max(drift);
221            if !sum.is_finite() {
222                return Err(ScoringError::ArithmeticOverflow(
223                    "vector calibration drift accumulation is not finite".into(),
224                ));
225            }
226        }
227        Ok(Self {
228            reference_k: reference_target.candidate_k,
229            candidate_k: candidate_target.candidate_k,
230            probe_count: probe_distances.len(),
231            mean_absolute_drift: sum / probe_distances.len() as f64,
232            max_absolute_drift: max,
233        })
234    }
235}