Skip to main content

semantic_operators/
question.rs

1//! Validated provider-independent questions and regions.
2use crate::{AnswerType, Error, ErrorKind};
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5fn invalid(message: &str) -> Error {
6    Error::new(ErrorKind::InvalidQuestion, message)
7}
8fn instructions(value: String) -> Result<String, Error> {
9    if is_blank(&value) {
10        Err(invalid("instructions must be a non-empty string"))
11    } else {
12        Ok(value)
13    }
14}
15fn confidence(value: f64) -> Result<f64, Error> {
16    if value.is_finite() && (0.0..=1.0).contains(&value) {
17        Ok(value)
18    } else {
19        Err(invalid(
20            "min_confidence must be a finite number from 0 to 1",
21        ))
22    }
23}
24fn labels<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<(), Error> {
25    let values: Vec<_> = values.into_iter().collect();
26    if values.len() < 2 {
27        return Err(invalid("need at least 2 outcomes"));
28    }
29    let mut seen = std::collections::HashSet::new();
30    if values.iter().any(|v| v.is_empty() || !seen.insert(*v)) {
31        return Err(invalid("outcomes must be unique non-empty strings"));
32    }
33    Ok(())
34}
35/// A Boolean question with optional descriptions of each outcome.
36#[derive(Clone, PartialEq, Serialize, Deserialize)]
37#[serde(try_from = "BooleanFields")]
38pub struct Boolean {
39    instructions: String,
40    min_confidence: f64,
41    true_description: Option<String>,
42    false_description: Option<String>,
43}
44impl Boolean {
45    /// Validate instructions and construct a question without a confidence threshold.
46    ///
47    /// # Errors
48    /// Returns `InvalidQuestion` when instructions are empty or contain only
49    /// reference-defined whitespace.
50    pub fn new(text: impl Into<String>) -> Result<Self, Error> {
51        Ok(Self {
52            instructions: instructions(text.into())?,
53            min_confidence: 0.0,
54            true_description: None,
55            false_description: None,
56        })
57    }
58    /// Set descriptions for true and false, respectively.
59    pub fn with_descriptions(mut self, yes: Option<String>, no: Option<String>) -> Self {
60        self.true_description = yes;
61        self.false_description = no;
62        self
63    }
64    /// Description of the true outcome.
65    pub fn true_description(&self) -> Option<&str> {
66        self.true_description.as_deref()
67    }
68    /// Description of the false outcome.
69    pub fn false_description(&self) -> Option<&str> {
70        self.false_description.as_deref()
71    }
72}
73/// A choice among ordered named outcomes.
74#[derive(Clone, PartialEq, Serialize, Deserialize)]
75#[serde(try_from = "ChoiceFields")]
76pub struct Choice {
77    instructions: String,
78    min_confidence: f64,
79    options: IndexMap<String, Option<String>>,
80}
81impl Choice {
82    /// Validate instructions and at least two distinct nonempty option names.
83    ///
84    /// # Errors
85    /// Returns `InvalidQuestion` for blank instructions, fewer than two options,
86    /// or an empty option name. The ordered map supplies unique names.
87    pub fn new(
88        text: impl Into<String>,
89        options: IndexMap<String, Option<String>>,
90    ) -> Result<Self, Error> {
91        labels(options.keys().map(String::as_str))?;
92        Ok(Self {
93            instructions: instructions(text.into())?,
94            min_confidence: 0.0,
95            options,
96        })
97    }
98    /// Ordered option names and descriptions.
99    pub fn options(&self) -> &IndexMap<String, Option<String>> {
100        &self.options
101    }
102}
103/// An expected score on an ordered rubric.
104#[derive(Clone, PartialEq, Serialize, Deserialize)]
105#[serde(try_from = "ScoreFields")]
106pub struct Score {
107    instructions: String,
108    min_confidence: f64,
109    levels: Vec<String>,
110}
111impl Score {
112    /// Validate instructions and at least two unique nonempty levels.
113    ///
114    /// # Errors
115    /// Returns `InvalidQuestion` for blank instructions, fewer than two levels,
116    /// or empty or duplicate level descriptions.
117    pub fn new(text: impl Into<String>, levels: Vec<String>) -> Result<Self, Error> {
118        labels(levels.iter().map(String::as_str))?;
119        Ok(Self {
120            instructions: instructions(text.into())?,
121            min_confidence: 0.0,
122            levels,
123        })
124    }
125    /// Rubric levels in score order, starting at zero.
126    pub fn levels(&self) -> &[String] {
127        &self.levels
128    }
129}
130/// A request to locate a region in an image.
131#[derive(Clone, PartialEq, Serialize, Deserialize)]
132#[serde(try_from = "BoxQuestionFields")]
133pub struct BoxQuestion {
134    instructions: String,
135    min_confidence: f64,
136}
137impl BoxQuestion {
138    /// Construct a localization question.
139    ///
140    /// # Errors
141    /// Returns `InvalidQuestion` when instructions are empty or contain only
142    /// reference-defined whitespace.
143    pub fn new(text: impl Into<String>) -> Result<Self, Error> {
144        Ok(Self {
145            instructions: instructions(text.into())?,
146            min_confidence: 0.0,
147        })
148    }
149}
150macro_rules! common { ($($ty:ty),*) => {$(
151    impl $ty {
152        /// Original instructions, preserved without trimming.
153        pub fn instructions(&self)->&str { &self.instructions }
154        /// Minimum confidence required for a decision.
155        pub fn min_confidence(&self)->f64 { self.min_confidence }
156        /// Set a finite confidence threshold in [0, 1].
157        ///
158        /// # Errors
159        /// Returns `InvalidQuestion` if the threshold is nonfinite or outside `[0, 1]`.
160        pub fn with_min_confidence(mut self, value:f64)->Result<Self,Error> { self.min_confidence=confidence(value)?; Ok(self) }
161    }
162)*}; }
163common!(Boolean, Choice, Score, BoxQuestion);
164/// A heterogeneous question.
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "type", rename_all = "lowercase")]
167pub enum Question {
168    /// A yes/no question.
169    Boolean(Boolean),
170    /// An ordered choice.
171    Choice(Choice),
172    /// An expected rubric score.
173    Score(Score),
174    /// A localization question.
175    Box(BoxQuestion),
176}
177impl Question {
178    /// Original instructions.
179    pub fn instructions(&self) -> &str {
180        match self {
181            Self::Boolean(q) => q.instructions(),
182            Self::Choice(q) => q.instructions(),
183            Self::Score(q) => q.instructions(),
184            Self::Box(q) => q.instructions(),
185        }
186    }
187    /// Decision threshold.
188    pub fn min_confidence(&self) -> f64 {
189        match self {
190            Self::Boolean(q) => q.min_confidence(),
191            Self::Choice(q) => q.min_confidence(),
192            Self::Score(q) => q.min_confidence(),
193            Self::Box(q) => q.min_confidence(),
194        }
195    }
196    /// Schema name of this question type.
197    pub fn kind(&self) -> &'static str {
198        match self {
199            Self::Boolean(_) => "boolean",
200            Self::Choice(_) => "choice",
201            Self::Score(_) => "score",
202            Self::Box(_) => "box",
203        }
204    }
205}
206/// A question whose output has a statically known type.
207pub trait TypedQuestion: Clone + Into<Question> {
208    /// Type of a decided answer.
209    type Output: AnswerType;
210}
211macro_rules! typed {
212    ($ty:ty,$variant:ident,$output:ty) => {
213        impl From<$ty> for Question {
214            fn from(q: $ty) -> Self {
215                Self::$variant(q)
216            }
217        }
218        impl TypedQuestion for $ty {
219            type Output = $output;
220        }
221    };
222}
223typed!(Boolean, Boolean, bool);
224typed!(Choice, Choice, String);
225typed!(Score, Score, f64);
226typed!(BoxQuestion, Box, Region);
227/// Normalized image edges. Zero-area regions are valid.
228#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
229#[serde(try_from = "RegionFields")]
230pub struct Region {
231    top: f64,
232    bottom: f64,
233    left: f64,
234    right: f64,
235}
236impl Region {
237    /// Validate finite edges in [0, 1], with top <= bottom and left <= right.
238    ///
239    /// # Errors
240    /// Returns `InvalidArgument` if an edge is nonfinite or outside `[0, 1]`,
241    /// if top exceeds bottom, or if left exceeds right. Zero-area regions are valid.
242    pub fn new(top: f64, bottom: f64, left: f64, right: f64) -> Result<Self, Error> {
243        if [top, bottom, left, right]
244            .iter()
245            .any(|v| !v.is_finite() || !(0.0..=1.0).contains(v))
246            || top > bottom
247            || left > right
248        {
249            return Err(Error::new(
250                ErrorKind::InvalidArgument,
251                "region edges must be ordered finite numbers from 0 to 1",
252            ));
253        }
254        Ok(Self {
255            top,
256            bottom,
257            left,
258            right,
259        })
260    }
261    /// Top edge.
262    pub fn top(&self) -> f64 {
263        self.top
264    }
265    /// Bottom edge.
266    pub fn bottom(&self) -> f64 {
267        self.bottom
268    }
269    /// Left edge.
270    pub fn left(&self) -> f64 {
271        self.left
272    }
273    /// Right edge.
274    pub fn right(&self) -> f64 {
275        self.right
276    }
277    /// Normalized width.
278    pub fn width(&self) -> f64 {
279        self.right - self.left
280    }
281    /// Normalized height.
282    pub fn height(&self) -> f64 {
283        self.bottom - self.top
284    }
285    /// Center as (x, y).
286    pub fn center(&self) -> (f64, f64) {
287        (
288            (self.left + self.right) / 2.0,
289            (self.top + self.bottom) / 2.0,
290        )
291    }
292    /// Grow each side by a fraction of the region size, clipping to the image.
293    ///
294    /// # Errors
295    /// Returns `InvalidArgument` for nonfinite padding or resulting invalid
296    /// edges, such as negative padding that shrinks past the opposite edge.
297    pub fn padded(&self, fraction: f64) -> Result<Self, Error> {
298        if !fraction.is_finite() {
299            return Err(Error::new(
300                ErrorKind::InvalidArgument,
301                "padding must be finite",
302            ));
303        }
304        let dx = self.width() * fraction;
305        let dy = self.height() * fraction;
306        Self::new(
307            (self.top - dy).max(0.0),
308            (self.bottom + dy).min(1.0),
309            (self.left - dx).max(0.0),
310            (self.right + dx).min(1.0),
311        )
312    }
313    /// Pixel edges (left, top, right, bottom), using ties-to-even rounding.
314    ///
315    /// ```
316    /// use semantic_operators::Region;
317    /// # fn main() -> semantic_operators::Result<()> {
318    /// let region = Region::new(0.2, 0.6, 0.1, 0.5)?;
319    /// assert_eq!(region.pixels(1000, 500)?, (100, 100, 500, 300));
320    /// # Ok(())
321    /// # }
322    /// ```
323    ///
324    /// # Errors
325    /// Returns `InvalidArgument` if either pixel dimension is zero.
326    pub fn pixels(&self, width: u32, height: u32) -> Result<(u32, u32, u32, u32), Error> {
327        if width == 0 || height == 0 {
328            return Err(Error::new(
329                ErrorKind::InvalidArgument,
330                "pixel dimensions must be positive",
331            ));
332        }
333        Ok((
334            (self.left * f64::from(width)).round_ties_even() as u32,
335            (self.top * f64::from(height)).round_ties_even() as u32,
336            (self.right * f64::from(width)).round_ties_even() as u32,
337            (self.bottom * f64::from(height)).round_ties_even() as u32,
338        ))
339    }
340}
341
342#[derive(Deserialize)]
343#[serde(deny_unknown_fields)]
344struct BooleanFields {
345    instructions: String,
346    #[serde(default)]
347    min_confidence: f64,
348    #[serde(default)]
349    true_description: Option<String>,
350    #[serde(default)]
351    false_description: Option<String>,
352}
353impl TryFrom<BooleanFields> for Boolean {
354    type Error = Error;
355    fn try_from(v: BooleanFields) -> Result<Self, Error> {
356        Self::new(v.instructions)?
357            .with_descriptions(v.true_description, v.false_description)
358            .with_min_confidence(v.min_confidence)
359    }
360}
361#[derive(Deserialize)]
362#[serde(deny_unknown_fields)]
363struct ChoiceFields {
364    instructions: String,
365    #[serde(default)]
366    min_confidence: f64,
367    options: IndexMap<String, Option<String>>,
368}
369impl TryFrom<ChoiceFields> for Choice {
370    type Error = Error;
371    fn try_from(v: ChoiceFields) -> Result<Self, Error> {
372        Self::new(v.instructions, v.options)?.with_min_confidence(v.min_confidence)
373    }
374}
375#[derive(Deserialize)]
376#[serde(deny_unknown_fields)]
377struct ScoreFields {
378    instructions: String,
379    #[serde(default)]
380    min_confidence: f64,
381    levels: Vec<String>,
382}
383impl TryFrom<ScoreFields> for Score {
384    type Error = Error;
385    fn try_from(v: ScoreFields) -> Result<Self, Error> {
386        Self::new(v.instructions, v.levels)?.with_min_confidence(v.min_confidence)
387    }
388}
389#[derive(Deserialize)]
390#[serde(deny_unknown_fields)]
391struct BoxQuestionFields {
392    instructions: String,
393    #[serde(default)]
394    min_confidence: f64,
395}
396impl TryFrom<BoxQuestionFields> for BoxQuestion {
397    type Error = Error;
398    fn try_from(v: BoxQuestionFields) -> Result<Self, Error> {
399        Self::new(v.instructions)?.with_min_confidence(v.min_confidence)
400    }
401}
402#[derive(Deserialize)]
403#[serde(deny_unknown_fields)]
404struct RegionFields {
405    top: f64,
406    bottom: f64,
407    left: f64,
408    right: f64,
409}
410impl TryFrom<RegionFields> for Region {
411    type Error = Error;
412    fn try_from(v: RegionFields) -> Result<Self, Error> {
413        Self::new(v.top, v.bottom, v.left, v.right)
414    }
415}
416
417macro_rules! private_debug {
418    ($($ty:ty),*) => {$(
419        impl std::fmt::Debug for $ty {
420            fn fmt(&self, f:&mut std::fmt::Formatter<'_>)->std::fmt::Result {
421                f.debug_struct(stringify!($ty)).field("min_confidence",&self.min_confidence).finish_non_exhaustive()
422            }
423        }
424    )*};
425}
426private_debug!(Boolean, Choice, Score, BoxQuestion);
427
428// Python str.strip includes the four ASCII information separators in addition
429// to Unicode White_Space; Rust str::trim does not.
430pub(crate) fn is_blank(value: &str) -> bool {
431    value
432        .chars()
433        .all(|c| c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c))
434}