1use std::collections::BTreeMap;
9
10use rustev_contract::calibration::{CalibrationArtifact, CalibrationMethod, CalibrationParameters};
11use rustev_contract::decimal::Decimal;
12use rustev_contract::ids::{ArtifactId, CalibrationId};
13
14use crate::kinds::{CalibratedProbability, KindError, softmax};
15
16#[derive(Debug, Clone)]
18pub struct BindingCheck<'a> {
19 pub artifact: &'a ArtifactId,
20 pub task: &'a str,
21 pub question: &'a str,
22 pub options: &'a [String],
23}
24
25#[derive(Debug, Clone, Copy)]
27pub enum CalibrationInput<'a> {
28 Logits(&'a BTreeMap<String, f64>),
29 Distribution(&'a BTreeMap<String, f64>),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum CalibrationError {
35 Binding(String),
37 Parameters(String),
39 Input(KindError),
41}
42
43impl std::fmt::Display for CalibrationError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 CalibrationError::Binding(s) => write!(f, "calibration binding: {s}"),
47 CalibrationError::Parameters(s) => write!(f, "calibration parameters: {s}"),
48 CalibrationError::Input(e) => write!(f, "calibration input: {e}"),
49 }
50 }
51}
52
53impl std::error::Error for CalibrationError {}
54
55pub fn check_binding(
58 artifact: &CalibrationArtifact,
59 check: &BindingCheck<'_>,
60) -> Result<(), CalibrationError> {
61 check_static(artifact, check.task, check.question, check.options)?;
62 if &artifact.binding.artifact != check.artifact {
63 return Err(CalibrationError::Binding(format!(
64 "artifact {} is not the bound {}",
65 artifact.binding.artifact, check.artifact
66 )));
67 }
68 Ok(())
69}
70
71pub fn check_static(
74 artifact: &CalibrationArtifact,
75 task: &str,
76 question: &str,
77 options: &[String],
78) -> Result<(), CalibrationError> {
79 let check = BindingCheck {
80 artifact: &artifact.binding.artifact,
81 task,
82 question,
83 options,
84 };
85 let b = &artifact.binding;
86 if b.task != check.task {
87 return Err(CalibrationError::Binding(format!(
88 "task {:?} is not {:?}",
89 b.task, check.task
90 )));
91 }
92 if b.question != check.question {
93 return Err(CalibrationError::Binding(format!(
94 "question {:?} is not {:?}",
95 b.question, check.question
96 )));
97 }
98 if artifact.options != check.options {
99 return Err(CalibrationError::Binding(
100 "options differ from the step's declared options".into(),
101 ));
102 }
103 match b.method {
104 CalibrationMethod::Temperature1 => {}
105 }
106 temperature(artifact).map(|_| ())
107}
108
109fn temperature(artifact: &CalibrationArtifact) -> Result<f64, CalibrationError> {
110 let CalibrationParameters::Temperature { temperature } = &artifact.parameters;
111 let max = Decimal::from_i64(1000);
112 if *temperature <= Decimal::ZERO || *temperature > max {
113 return Err(CalibrationError::Parameters(format!(
114 "temperature {temperature} outside (0, 1000]"
115 )));
116 }
117 Ok(temperature.to_f64_nearest())
118}
119
120pub fn apply(
123 artifact: &CalibrationArtifact,
124 id: &CalibrationId,
125 check: &BindingCheck<'_>,
126 input: CalibrationInput<'_>,
127 tolerance: Decimal,
128) -> Result<CalibratedProbability, CalibrationError> {
129 check_binding(artifact, check)?;
130 let t = temperature(artifact)?;
131 let (map, is_logits) = match input {
132 CalibrationInput::Logits(m) => (m, true),
133 CalibrationInput::Distribution(m) => (m, false),
134 };
135 if let Some(extra) = map.keys().find(|k| !check.options.contains(k)) {
136 return Err(CalibrationError::Input(KindError(format!(
137 "undeclared option {extra:?}"
138 ))));
139 }
140 let mut z = Vec::with_capacity(check.options.len());
141 for o in check.options {
142 let v = *map
143 .get(o)
144 .ok_or_else(|| CalibrationError::Input(KindError(format!("missing option {o:?}"))))?;
145 if !v.is_finite() || (!is_logits && v < 0.0) {
146 return Err(CalibrationError::Input(KindError(format!(
147 "invalid value for option {o:?}"
148 ))));
149 }
150 z.push(if is_logits {
151 v
152 } else if v == 0.0 {
153 f64::NEG_INFINITY
154 } else {
155 crate::math::ln(v)
156 });
157 }
158 let max = z.iter().copied().fold(f64::NEG_INFINITY, f64::max);
161 let z: Vec<f64> = z
162 .iter()
163 .map(|v| {
164 if *v == f64::NEG_INFINITY {
165 *v
166 } else {
167 (v - max) / t
168 }
169 })
170 .collect();
171 if !is_logits {
172 let sum: f64 = check.options.iter().fold(0.0, |acc, o| acc + map[o]);
174 if (sum - 1.0).abs() > tolerance.to_f64_nearest() {
175 return Err(CalibrationError::Input(KindError(format!(
176 "masses sum to {sum}"
177 ))));
178 }
179 }
180 if z.iter().all(|v| *v == f64::NEG_INFINITY) {
181 return Err(CalibrationError::Input(KindError(
182 "no option has mass".into(),
183 )));
184 }
185 let masses = softmax(&z);
186 CalibratedProbability::from_applied(
187 id.clone(),
188 check.options.to_vec(),
189 masses,
190 tolerance.to_f64_nearest(),
191 )
192 .map_err(CalibrationError::Input)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use rustev_contract::calibration::CalibrationBinding;
199 use rustev_contract::ids::DatasetId;
200 use rustev_contract::{Identified, schema};
201
202 fn id(c: char) -> String {
203 format!("sha256:{}", c.to_string().repeat(64))
204 }
205
206 fn artifact(t: &str) -> CalibrationArtifact {
207 CalibrationArtifact {
208 schema: schema::CALIBRATION.into(),
209 binding: CalibrationBinding {
210 artifact: ArtifactId::parse(&id('a')).unwrap(),
211 task: "support.topic".into(),
212 question: "topic".into(),
213 dataset: DatasetId::parse(&id('d')).unwrap(),
214 method: CalibrationMethod::Temperature1,
215 },
216 options: vec!["a".into(), "b".into()],
217 parameters: CalibrationParameters::Temperature {
218 temperature: Decimal::parse(t).unwrap(),
219 },
220 }
221 }
222
223 fn tol() -> Decimal {
224 Decimal::parse("0.000001").unwrap()
225 }
226
227 #[test]
228 fn temperature_one_on_logits_is_softmax_and_higher_temperature_flattens() {
229 let art = artifact("1");
230 let cid = art.id().unwrap();
231 let aid = ArtifactId::parse(&id('a')).unwrap();
232 let opts = vec!["a".to_string(), "b".to_string()];
233 let check = BindingCheck {
234 artifact: &aid,
235 task: "support.topic",
236 question: "topic",
237 options: &opts,
238 };
239 let logits: BTreeMap<String, f64> = [("a".to_string(), 2.0), ("b".to_string(), 0.0)].into();
240 let p1 = apply(&art, &cid, &check, CalibrationInput::Logits(&logits), tol()).unwrap();
241 let hot = artifact("2");
242 let p2 = apply(
243 &hot,
244 &hot.id().unwrap(),
245 &check,
246 CalibrationInput::Logits(&logits),
247 tol(),
248 )
249 .unwrap();
250 assert!(p1.masses()[0] > p2.masses()[0]);
251 assert!(p2.masses()[0] > 0.5);
252 assert_eq!(p1.calibration(), &cid);
253 }
254
255 #[test]
256 fn a_distribution_input_keeps_zero_mass_at_zero() {
257 let art = artifact("0.5");
258 let aid = ArtifactId::parse(&id('a')).unwrap();
259 let opts = vec!["a".to_string(), "b".to_string()];
260 let check = BindingCheck {
261 artifact: &aid,
262 task: "support.topic",
263 question: "topic",
264 options: &opts,
265 };
266 let d: BTreeMap<String, f64> = [("a".to_string(), 1.0), ("b".to_string(), 0.0)].into();
267 let p = apply(
268 &art,
269 &art.id().unwrap(),
270 &check,
271 CalibrationInput::Distribution(&d),
272 tol(),
273 )
274 .unwrap();
275 assert_eq!(p.masses(), &[1.0, 0.0]);
276 }
277
278 #[test]
279 fn a_mismatched_binding_is_refused() {
280 let art = artifact("1");
281 let other = ArtifactId::parse(&id('b')).unwrap();
282 let aid = ArtifactId::parse(&id('a')).unwrap();
283 let opts = vec!["a".to_string(), "b".to_string()];
284 let reordered = vec!["b".to_string(), "a".to_string()];
285 for check in [
286 BindingCheck {
287 artifact: &other,
288 task: "support.topic",
289 question: "topic",
290 options: &opts,
291 },
292 BindingCheck {
293 artifact: &aid,
294 task: "other",
295 question: "topic",
296 options: &opts,
297 },
298 BindingCheck {
299 artifact: &aid,
300 task: "support.topic",
301 question: "other",
302 options: &opts,
303 },
304 BindingCheck {
305 artifact: &aid,
306 task: "support.topic",
307 question: "topic",
308 options: &reordered,
309 },
310 ] {
311 assert!(matches!(
312 check_binding(&art, &check),
313 Err(CalibrationError::Binding(_))
314 ));
315 }
316 }
317
318 #[test]
319 fn temperature_bounds_are_enforced() {
320 let aid = ArtifactId::parse(&id('a')).unwrap();
321 let opts = vec!["a".to_string(), "b".to_string()];
322 let check = BindingCheck {
323 artifact: &aid,
324 task: "support.topic",
325 question: "topic",
326 options: &opts,
327 };
328 assert!(check_binding(&artifact("1000"), &check).is_ok());
329 assert!(check_binding(&artifact("1000.000000001"), &check).is_err());
330 assert!(check_binding(&artifact("0"), &check).is_err());
331 assert!(check_binding(&artifact("-1"), &check).is_err());
332 }
333}