Skip to main content

semantic_operators/
answer.rs

1//! Central answer normalization and typed projections.
2use crate::{Error, ErrorKind, Question, Region};
3use indexmap::IndexMap;
4use serde_json::Value;
5use std::sync::Arc;
6/// Ordered outcome probabilities.
7pub type Probabilities = IndexMap<String, f64>;
8/// Metadata actually reported by one provider call.
9#[derive(Clone, Debug, PartialEq, Eq, Default)]
10pub struct Call {
11    /// Provider identity.
12    pub provider: String,
13    /// Reported model identity, when known.
14    pub model: Option<String>,
15    /// Reported input token usage.
16    pub input_tokens: Option<u64>,
17    /// Reported output token usage.
18    pub output_tokens: Option<u64>,
19}
20/// A heterogeneous decided value.
21#[derive(Clone, Debug, PartialEq)]
22pub enum AnswerValue {
23    /// Boolean outcome.
24    Boolean(bool),
25    /// Chosen option name.
26    Choice(String),
27    /// Expected score.
28    Score(f64),
29    /// Image region.
30    Box(Region),
31}
32/// Conversion supported by a typed answer.
33pub trait AnswerType: Clone + Send + Sync + 'static {
34    /// Check and copy a heterogeneous value.
35    ///
36    /// # Errors
37    /// Returns an error when the dynamic value cannot be represented by this
38    /// output type. Built-in conversions report `InvalidResponse` for a variant mismatch.
39    fn from_answer_value(value: &AnswerValue) -> Result<Self, Error>;
40}
41macro_rules! typed {
42    ($ty:ty,$variant:ident) => {
43        impl AnswerType for $ty {
44            fn from_answer_value(v: &AnswerValue) -> Result<Self, Error> {
45                match v {
46                    AnswerValue::$variant(x) => Ok(x.clone()),
47                    _ => Err(invalid("answer type does not match the requested output")),
48                }
49            }
50        }
51    };
52}
53typed!(bool, Boolean);
54typed!(String, Choice);
55typed!(f64, Score);
56typed!(Region, Box);
57impl AnswerType for AnswerValue {
58    fn from_answer_value(v: &AnswerValue) -> Result<Self, Error> {
59        Ok(v.clone())
60    }
61}
62/// A normalized answer. Undecided answers retain evidence and call metadata.
63#[derive(Clone, PartialEq)]
64pub struct Answer<T = AnswerValue> {
65    value: Option<T>,
66    candidate: AnswerValue,
67    probabilities: Probabilities,
68    confidence: f64,
69    call: Option<Arc<Call>>,
70    raw: Option<Value>,
71}
72impl<T> std::fmt::Debug for Answer<T> {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("Answer")
75            .field("decided", &self.value.is_some())
76            .field("confidence", &self.confidence)
77            .field("call", &self.call)
78            .finish_non_exhaustive()
79    }
80}
81impl<T> Answer<T> {
82    /// Decided value, or None for abstention.
83    pub fn value(&self) -> Option<&T> {
84        self.value.as_ref()
85    }
86    /// Whether this answer is decided. False and zero are decided values.
87    pub fn decided(&self) -> bool {
88        self.value.is_some()
89    }
90    /// Reported decision confidence.
91    pub fn confidence(&self) -> f64 {
92        self.confidence
93    }
94    /// Ordered probabilities, including for abstentions.
95    pub fn probabilities(&self) -> &Probabilities {
96        &self.probabilities
97    }
98    /// Metadata of the actual call, if reported.
99    pub fn call(&self) -> Option<&Arc<Call>> {
100        self.call.as_ref()
101    }
102    /// Optional private provider response data, excluded from Debug and wire encoding.
103    pub fn raw(&self) -> Option<&Value> {
104        self.raw.as_ref()
105    }
106    /// Attach reported call metadata.
107    pub fn with_call(mut self, call: Arc<Call>) -> Self {
108        self.call = Some(call);
109        self
110    }
111    /// Attach raw provider data for explicit inspection.
112    pub fn with_raw(mut self, raw: Value) -> Self {
113        self.raw = Some(raw);
114        self
115    }
116    /// Apply a threshold without restoring any previously undecided value.
117    ///
118    /// # Errors
119    /// Returns `InvalidArgument` if the threshold is nonfinite or outside `[0, 1]`.
120    pub fn with_min_confidence(mut self, threshold: f64) -> Result<Self, Error> {
121        if !threshold.is_finite() || !(0.0..=1.0).contains(&threshold) {
122            return Err(Error::new(
123                ErrorKind::InvalidArgument,
124                "threshold must be finite and from 0 to 1",
125            ));
126        }
127        if self.confidence < threshold {
128            self.value = None;
129        }
130        Ok(self)
131    }
132    /// Explicitly remove a decision while retaining all evidence.
133    pub fn abstain(mut self) -> Self {
134        self.value = None;
135        self
136    }
137}
138impl Answer {
139    /// Validate the retained provider candidate against the actual requested question.
140    /// Existing abstentions remain undecided, including explicit provider abstentions.
141    ///
142    /// # Errors
143    /// Returns the normalization errors from [`make_answer`] if the retained
144    /// candidate or distribution is incompatible with `question`.
145    pub fn revalidate(&self, question: &Question) -> Result<Self, Error> {
146        let explicit = if matches!(question, Question::Box(_)) {
147            Some(self.confidence)
148        } else {
149            None
150        };
151        let mut answer = make_answer(
152            question,
153            self.candidate.clone(),
154            self.probabilities.clone(),
155            explicit,
156        )?;
157        if self.value.is_none() {
158            answer.value = None;
159        }
160        answer.call = self.call.clone();
161        answer.raw = self.raw.clone();
162        Ok(answer)
163    }
164    /// Project into a typed answer, checking type even when the answer is undecided.
165    ///
166    /// # Errors
167    /// Propagates conversion errors from [`AnswerType::from_answer_value`],
168    /// including a type mismatch even when this answer is undecided.
169    pub fn typed<T: AnswerType>(&self) -> Result<Answer<T>, Error> {
170        let candidate = T::from_answer_value(&self.candidate)?;
171        Ok(Answer {
172            value: self.value.as_ref().map(|_| candidate),
173            candidate: self.candidate.clone(),
174            probabilities: self.probabilities.clone(),
175            confidence: self.confidence,
176            call: self.call.clone(),
177            raw: self.raw.clone(),
178        })
179    }
180}
181fn invalid(msg: &str) -> Error {
182    Error::new(ErrorKind::InvalidResponse, msg)
183}
184/// Validate a provider candidate and centrally apply ties and confidence thresholds.
185///
186/// A valid response below the threshold is successful but undecided; its
187/// probabilities remain available for inspection.
188///
189/// ```
190/// use semantic_operators::{AnswerValue, Boolean, IndexMap, Question, make_answer};
191/// # fn main() -> semantic_operators::Result<()> {
192/// let question = Question::from(Boolean::new("Does this need review?")?
193///     .with_min_confidence(0.8)?);
194/// let probabilities = IndexMap::from([("true".into(), 0.7), ("false".into(), 0.3)]);
195/// let answer = make_answer(&question, AnswerValue::Boolean(true), probabilities, None)?
196///     .typed::<bool>()?;
197/// assert_eq!(answer.value(), None);
198/// assert_eq!(answer.confidence(), 0.7);
199/// assert_eq!(answer.probabilities()["true"], 0.7);
200/// # Ok(())
201/// # }
202/// ```
203///
204/// # Errors
205/// Returns `InvalidResponse` for a mismatched value type, invalid or incomplete
206/// probabilities, disagreement between the value and distribution, or invalid
207/// Box confidence. Ties and insufficient confidence produce a successful
208/// undecided answer rather than an error.
209pub fn make_answer(
210    question: &Question,
211    value: AnswerValue,
212    probabilities: Probabilities,
213    explicit_confidence: Option<f64>,
214) -> Result<Answer, Error> {
215    if !matches!(question, Question::Box(_)) && explicit_confidence.is_some() {
216        return Err(invalid("explicit confidence is only valid for Box answers"));
217    }
218    let (confidence, tied) = match (question, &value) {
219        (Question::Boolean(_), AnswerValue::Boolean(v)) => {
220            distribution(&probabilities, &["true", "false"])?;
221            let yes = probabilities["true"];
222            let no = probabilities["false"];
223            let tied = yes == no;
224            if !tied && *v != (yes > no) {
225                return Err(invalid("Boolean value disagrees with its probabilities"));
226            }
227            (yes.max(no), tied)
228        }
229        (Question::Choice(q), AnswerValue::Choice(v)) => {
230            let labels: Vec<_> = q.options().keys().map(String::as_str).collect();
231            distribution(&probabilities, &labels)?;
232            let top = probabilities.values().copied().fold(0.0, f64::max);
233            if probabilities.get(v) != Some(&top) {
234                return Err(invalid("Choice value must name a most likely outcome"));
235            }
236            (
237                top,
238                probabilities.values().filter(|p| **p == top).count() > 1,
239            )
240        }
241        (Question::Score(q), AnswerValue::Score(v)) => {
242            let labels: Vec<_> = q.levels().iter().map(String::as_str).collect();
243            distribution(&probabilities, &labels)?;
244            let highest = q.levels().len() - 1;
245            if !v.is_finite() || *v < 0.0 || *v > highest as f64 {
246                return Err(invalid("Score value is outside the rubric"));
247            }
248            let expected = python_sum(
249                q.levels()
250                    .iter()
251                    .enumerate()
252                    .map(|(i, label)| i as f64 * probabilities[label]),
253            );
254            let margin = 0.005 * (1.0 + (highest as f64 * (highest as f64 + 1.0) / 2.0));
255            if (*v - expected).abs() > margin {
256                return Err(invalid("Score value disagrees with its probabilities"));
257            }
258            let nearest = ((*v + 0.5).floor() as usize).min(highest);
259            (probabilities[&q.levels()[nearest]], false)
260        }
261        (Question::Box(_), AnswerValue::Box(_)) => {
262            if !probabilities.is_empty() {
263                return Err(invalid("Box answers have no probabilities"));
264            }
265            let c = explicit_confidence.unwrap_or(0.0);
266            if !c.is_finite() || !(0.0..=1.0).contains(&c) {
267                return Err(invalid("Box confidence must be finite and from 0 to 1"));
268            }
269            (c, false)
270        }
271        _ => return Err(invalid("answer value type does not match question")),
272    };
273    let decided = if tied || confidence < question.min_confidence() {
274        None
275    } else {
276        Some(value.clone())
277    };
278    Ok(Answer {
279        value: decided,
280        candidate: value,
281        probabilities,
282        confidence,
283        call: None,
284        raw: None,
285    })
286}
287fn distribution(p: &Probabilities, labels: &[&str]) -> Result<(), Error> {
288    if p.len() != labels.len() || labels.iter().any(|label| !p.contains_key(*label)) {
289        return Err(invalid(
290            "probabilities must cover exactly the question outcomes",
291        ));
292    }
293    if p.values()
294        .any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
295    {
296        return Err(invalid("probabilities must be finite numbers from 0 to 1"));
297    }
298    if (accurate_sum(p.values().copied()) - 1.0).abs() > 0.005 * labels.len() as f64 + 1e-9 {
299        return Err(invalid("probabilities must sum to 1"));
300    }
301    Ok(())
302}
303// Nonoverlapping partial sums with the final half-even correction used by math.fsum.
304// Adapted from CPython math.fsum; see NOTICE.md and LICENSE-PYTHON.
305fn accurate_sum(values: impl IntoIterator<Item = f64>) -> f64 {
306    let mut partials: Vec<f64> = Vec::new();
307    for mut x in values {
308        let mut i = 0;
309        for j in 0..partials.len() {
310            let mut y = partials[j];
311            if x.abs() < y.abs() {
312                std::mem::swap(&mut x, &mut y);
313            }
314            let hi = x + y;
315            let lo = y - (hi - x);
316            if lo != 0.0 {
317                partials[i] = lo;
318                i += 1;
319            }
320            x = hi;
321        }
322        partials.truncate(i);
323        partials.push(x);
324    }
325    let mut hi = partials.pop().unwrap_or(0.0);
326    let mut lo = 0.0;
327    while let Some(y) = partials.pop() {
328        let x = hi;
329        hi = x + y;
330        lo = y - (hi - x);
331        if lo != 0.0 {
332            break;
333        }
334    }
335    if partials
336        .last()
337        .is_some_and(|last| (lo < 0.0 && *last < 0.0) || (lo > 0.0 && *last > 0.0))
338    {
339        let y = lo * 2.0;
340        let x = hi + y;
341        if y == x - hi {
342            hi = x;
343        }
344    }
345    hi
346}
347/// Python-compatible `round(1 - p, 10)` for a finite probability in [0, 1].
348/// Invalid inputs return an argument error rather than entering normalization.
349///
350/// # Errors
351/// Returns `InvalidArgument` for nonfinite probabilities or values outside
352/// `[0, 1]`, and `Runtime` if decimal conversion fails.
353pub fn complement(p: f64) -> Result<f64, Error> {
354    if !p.is_finite() || !(0.0..=1.0).contains(&p) {
355        return Err(Error::new(
356            ErrorKind::InvalidArgument,
357            "probability must be finite and from 0 to 1",
358        ));
359    }
360    // Formatting rounds the binary float directly to decimal, avoiding the extra
361    // rounding introduced by multiplying by 10^10 before rounding to an integer.
362    format!("{:.10}", 1.0 - p)
363        .parse()
364        .map_err(|_| Error::new(ErrorKind::Runtime, "decimal conversion failed"))
365}
366/// Return a nonnegative JSON integer token count, rejecting booleans and floats.
367pub fn token_count(value: &Value) -> Option<u64> {
368    value.as_u64()
369}
370
371// CPython 3.12+ sum of floats uses Neumaier compensated summation.
372fn python_sum(values: impl IntoIterator<Item = f64>) -> f64 {
373    let mut hi: f64 = 0.0;
374    let mut lo = 0.0;
375    for x in values {
376        let t = hi + x;
377        lo += if hi.abs() >= x.abs() {
378            (hi - t) + x
379        } else {
380            (x - t) + hi
381        };
382        hi = t;
383    }
384    hi + lo
385}